diff --git a/apps/webapp/app/hooks/useOrganizations.ts b/apps/webapp/app/hooks/useOrganizations.ts index ff25b625a..6b948092d 100644 --- a/apps/webapp/app/hooks/useOrganizations.ts +++ b/apps/webapp/app/hooks/useOrganizations.ts @@ -19,10 +19,10 @@ export function useOrganizations() { } export function useOptionalOrganization() { - const orgs = useOrganizations(); + const orgs = useOptionalOrganizations(); const routeMatch = useMatchesData("routes/_app.orgs.$organizationSlug"); - if (!routeMatch || !routeMatch.data.organization) { + if (!orgs || !routeMatch || !routeMatch.data.organization) { return undefined; } diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts new file mode 100644 index 000000000..4ac9367cb --- /dev/null +++ b/apps/webapp/app/models/member.server.ts @@ -0,0 +1,104 @@ +import { prisma } from "~/db.server"; + +export async function getOrganizationTeamMembers({ + userId, + slug, +}: { + userId: string; + slug: string; +}) { + const org = await prisma.organization.findFirst({ + where: { slug, members: { some: { userId } } }, + select: { + members: { + select: { + id: true, + role: true, + user: { + select: { + id: true, + name: true, + email: true, + avatarUrl: true, + }, + }, + }, + }, + }, + }); + + if (!org) { + return null; + } + + return org.members; +} + +export async function removeTeamMember({ + userId, + slug, + memberId, +}: { + userId: string; + slug: string; + memberId: string; +}) { + const org = await prisma.organization.findFirst({ + where: { slug, members: { some: { userId } } }, + }); + + if (!org) { + throw new Error("User does not have access to this organization"); + } + + return prisma.orgMember.delete({ + where: { + id: memberId, + }, + include: { + organization: true, + user: true, + }, + }); +} + +export async function inviteMembers({ + slug, + emails, + userId, +}: { + slug: string; + emails: string[]; + userId: string; +}) { + const org = await prisma.organization.findFirst({ + where: { slug, members: { some: { userId } } }, + }); + + if (!org) { + throw new Error("User does not have access to this organization"); + } + + const created = await prisma.orgMemberInvite.createMany({ + data: emails.map((email) => ({ + email, + organizationId: org.id, + inviterId: userId, + })), + skipDuplicates: true, + }); + + return await prisma.orgMemberInvite.findMany({ + where: { + organizationId: org.id, + inviterId: userId, + email: { + in: emails, + }, + }, + include: { + organization: true, + inviter: true, + }, + }); +} diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index c4d9d6ada..1f0fd9d75 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -191,65 +191,3 @@ function envSlug(environmentType: RuntimeEnvironment["type"]) { } } } - -export async function getOrganizationTeamMembers({ - userId, - slug, -}: { - userId: string; - slug: string; -}) { - const org = await prisma.organization.findFirst({ - where: { slug, members: { some: { userId } } }, - select: { - members: { - select: { - id: true, - role: true, - user: { - select: { - id: true, - name: true, - email: true, - avatarUrl: true, - }, - }, - }, - }, - }, - }); - - if (!org) { - return null; - } - - return org.members; -} - -export async function removeTeamMember({ - userId, - slug, - memberId, -}: { - userId: string; - slug: string; - memberId: string; -}) { - const org = await prisma.organization.findFirst({ - where: { slug, members: { some: { userId } } }, - }); - - if (!org) { - throw new Error("User does not have access to this organization"); - } - - return prisma.orgMember.delete({ - where: { - id: memberId, - }, - include: { - organization: true, - user: true, - }, - }); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index 16289a31b..e76c6680d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -22,10 +22,13 @@ import { FormTitle } from "~/components/primitives/FormTitle"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; +import { env } from "~/env.server"; import { useOrganization } from "~/hooks/useOrganizations"; +import { inviteMembers } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; +import { scheduleEmail } from "~/services/email.server"; import { requireUserId } from "~/services/session.server"; -import { organizationTeamPath } from "~/utils/pathBuilder"; +import { acceptInvitePath, organizationTeamPath } from "~/utils/pathBuilder"; const schema = z.object({ emails: z.preprocess((i) => { @@ -56,16 +59,30 @@ export const action: ActionFunction = async ({ request, params }) => { } try { - console.log("Valid submission", submission.value); - // const project = await createProject({ - // organizationSlug: organizationSlug, - // name: submission.value.projectName, - // userId, - // }); + const invites = await inviteMembers({ + slug: organizationSlug, + emails: submission.value.emails, + userId, + }); + + for (const invite of invites) { + try { + await scheduleEmail({ + email: "invite", + to: invite.email, + orgName: invite.organization.title, + inviterName: invite.inviter.name ?? undefined, + inviterEmail: invite.inviter.email, + inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`, + }); + } catch (error) { + console.error("Failed to send invite email"); + console.error(error); + } + } return redirectWithSuccessMessage( - //todo organizationTeamPath(organizationSlug), - "", + organizationTeamPath(invites[0].organization), request, simplur`${submission.value.emails.length} member[|s] invited` ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx index 51f3849d8..4ea3d7481 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx @@ -29,10 +29,6 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { useOrganization } from "~/hooks/useOrganizations"; import { useUser } from "~/hooks/useUser"; -import { - getOrganizationTeamMembers, - removeTeamMember, -} from "~/models/organization.server"; import { requireUserId } from "~/services/session.server"; import { titleCase } from "~/utils"; import { OrgAdminHeader } from "../_app.orgs.$organizationSlug._index/OrgAdminHeader"; @@ -45,6 +41,10 @@ import { import { Form, useActionData } from "@remix-run/react"; import { conform, useForm } from "@conform-to/react"; import { UserPlusIcon } from "@heroicons/react/20/solid"; +import { + getOrganizationTeamMembers, + removeTeamMember, +} from "~/models/member.server"; export const loader = async ({ request, params }: LoaderArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app/route.tsx b/apps/webapp/app/routes/_app/route.tsx index 1329d0065..5f3668fa9 100644 --- a/apps/webapp/app/routes/_app/route.tsx +++ b/apps/webapp/app/routes/_app/route.tsx @@ -55,11 +55,10 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => { }; export default function App() { - const location = useLocation(); const { impersonationId } = useTypedLoaderData(); - const isOrgChildPage = useIsProjectChildPage(); + const isProjectChildPage = useIsProjectChildPage(); - const showBackgroundGradient = !isOrgChildPage; + const showBackgroundGradient = !isProjectChildPage; return ( <> diff --git a/apps/webapp/app/services/email.server.ts b/apps/webapp/app/services/email.server.ts index 85d8fe072..30cc76274 100644 --- a/apps/webapp/app/services/email.server.ts +++ b/apps/webapp/app/services/email.server.ts @@ -29,7 +29,7 @@ export async function scheduleWelcomeEmail(user: User) { process.env.NODE_ENV === "development" ? 1000 * 60 : 1000 * 60 * 22; await workerQueue.enqueue( - "deliverEmail", + "scheduleEmail", { email: "welcome", to: user.email, @@ -39,6 +39,14 @@ export async function scheduleWelcomeEmail(user: User) { ); } +export async function scheduleEmail( + data: DeliverEmail, + delay?: { seconds: number } +) { + const runAt = delay ? new Date(Date.now() + delay.seconds * 1000) : undefined; + await workerQueue.enqueue("scheduleEmail", data, { runAt }); +} + export async function sendEmail(data: DeliverEmail) { return client.send(data); } diff --git a/apps/webapp/app/services/worker.server.ts b/apps/webapp/app/services/worker.server.ts index da945920a..e9b720128 100644 --- a/apps/webapp/app/services/worker.server.ts +++ b/apps/webapp/app/services/worker.server.ts @@ -26,15 +26,13 @@ import { DeliverScheduledEventService } from "./schedules/deliverScheduledEvent. import { prisma } from "~/db.server"; import { MissingConnectionCreatedService } from "./runs/missingConnectionCreated.server"; import { ApiConnectionCreatedService } from "./externalApis/apiConnectionCreated.server"; +import { sendEmail } from "./email.server"; +import { DeliverEmailSchema } from "@/../../packages/emails/src"; const workerCatalog = { organizationCreated: z.object({ id: z.string() }), endpointRegistered: z.object({ id: z.string() }), - deliverEmail: z.object({ - email: z.string(), - to: z.string(), - name: z.string().optional(), - }), + scheduleEmail: DeliverEmailSchema, githubAppInstallationDeleted: z.object({ id: z.string() }), githubPush: z.object({ branch: z.string(), @@ -237,12 +235,12 @@ function getWorkerQueue() { await service.call(payload.id); }, }, - deliverEmail: { + scheduleEmail: { queueName: "internal-queue", priority: 100, maxAttempts: 3, handler: async (payload, job) => { - // TODO: implement + await sendEmail(payload); }, }, startInitialProjectDeployment: { diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index f1f59b5d5..231c56371 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -15,6 +15,10 @@ export function accountPath() { return `/account`; } +export function acceptInvitePath(token: string) { + return `/accept-invite?token=${token}`; +} + // Org export function organizationPath(organization: OrgForPath) { return `/orgs/${organizationParam(organization)}`; diff --git a/apps/webapp/prisma/migrations/20230601165125_add_org_member_invite/migration.sql b/apps/webapp/prisma/migrations/20230601165125_add_org_member_invite/migration.sql new file mode 100644 index 000000000..4fa0df995 --- /dev/null +++ b/apps/webapp/prisma/migrations/20230601165125_add_org_member_invite/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "OrgMemberInvite" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" "OrgMemberRole" NOT NULL DEFAULT 'MEMBER', + "organizationId" TEXT NOT NULL, + "inviterId" TEXT NOT NULL, + "memberId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OrgMemberInvite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OrgMemberInvite_token_key" ON "OrgMemberInvite"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "OrgMemberInvite_memberId_key" ON "OrgMemberInvite"("memberId"); + +-- AddForeignKey +ALTER TABLE "OrgMemberInvite" ADD CONSTRAINT "OrgMemberInvite_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrgMemberInvite" ADD CONSTRAINT "OrgMemberInvite_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrgMemberInvite" ADD CONSTRAINT "OrgMemberInvite_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "OrgMember"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/webapp/prisma/migrations/20230601170659_invite_unique_orgid_email_constraint/migration.sql b/apps/webapp/prisma/migrations/20230601170659_invite_unique_orgid_email_constraint/migration.sql new file mode 100644 index 000000000..199b618b3 --- /dev/null +++ b/apps/webapp/prisma/migrations/20230601170659_invite_unique_orgid_email_constraint/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[organizationId,email]` on the table `OrgMemberInvite` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "OrgMemberInvite_organizationId_email_key" ON "OrgMemberInvite"("organizationId", "email"); diff --git a/apps/webapp/prisma/migrations/20230601170752_invite_remove_orgmember/migration.sql b/apps/webapp/prisma/migrations/20230601170752_invite_remove_orgmember/migration.sql new file mode 100644 index 000000000..fc07d1e15 --- /dev/null +++ b/apps/webapp/prisma/migrations/20230601170752_invite_remove_orgmember/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the column `memberId` on the `OrgMemberInvite` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "OrgMemberInvite" DROP CONSTRAINT "OrgMemberInvite_memberId_fkey"; + +-- DropIndex +DROP INDEX "OrgMemberInvite_memberId_key"; + +-- AlterTable +ALTER TABLE "OrgMemberInvite" DROP COLUMN "memberId"; diff --git a/apps/webapp/prisma/schema.prisma b/apps/webapp/prisma/schema.prisma index 66f00cd10..d2011c3ba 100644 --- a/apps/webapp/prisma/schema.prisma +++ b/apps/webapp/prisma/schema.prisma @@ -35,6 +35,7 @@ model User { marketingEmails Boolean @default(true) orgMemberships OrgMember[] + sentInvites OrgMemberInvite[] } enum AuthenticationMethod { @@ -60,6 +61,7 @@ model Organization { projects Project[] members OrgMember[] + invites OrgMemberInvite[] externalAccounts ExternalAccount[] connectionClients ApiConnectionClient[] sources TriggerSource[] @@ -189,6 +191,24 @@ enum OrgMemberRole { MEMBER } +model OrgMemberInvite { + id String @id @default(cuid()) + token String @unique @default(cuid()) + email String + role OrgMemberRole @default(MEMBER) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + inviter User @relation(fields: [inviterId], references: [id], onDelete: Cascade, onUpdate: Cascade) + inviterId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, email]) +} + model RuntimeEnvironment { id String @id @default(cuid()) slug String diff --git a/packages/emails/emails/invite.tsx b/packages/emails/emails/invite.tsx new file mode 100644 index 000000000..4c20f35b1 --- /dev/null +++ b/packages/emails/emails/invite.tsx @@ -0,0 +1,68 @@ +import { Container } from "@react-email/container"; +import { Head } from "@react-email/head"; +import { Html } from "@react-email/html"; +import { Image } from "./components/Image"; +import { Link } from "@react-email/link"; +import { Preview } from "@react-email/preview"; +import { Section } from "@react-email/section"; +import { Text } from "@react-email/text"; +import * as React from "react"; +import { Footer } from "./components/Footer"; +import { + main, + anchor, + h1, + container, + paragraphLight, +} from "./components/styles"; +import { z } from "zod"; + +export const InviteEmailSchema = z.object({ + email: z.literal("invite"), + orgName: z.string(), + inviterName: z.string().optional(), + inviterEmail: z.string(), + inviteLink: z.string().url(), +}); + +export default function Email({ + orgName, + inviterName, + inviterEmail, + inviteLink, +}: z.infer) { + return ( + + + {`You've been invited to ${orgName}`} +
+ + {`You've been invited to ${orgName}`} + + {inviterName ?? inviterEmail} has invited you to join their + organization on Trigger.dev. + + + Click here to view the invitation + + + Trigger.dev +
+ +
+ + ); +} diff --git a/packages/emails/package.json b/packages/emails/package.json index 890a3d2be..5d9ae4044 100644 --- a/packages/emails/package.json +++ b/packages/emails/package.json @@ -28,7 +28,12 @@ "zod": "^3.20.2" }, "devDependencies": { + "@types/react": "^18.0.21", "@trigger.dev/tsconfig": "workspace:*", - "@types/react": "^18.0.21" + "@types/node": "16", + "typescript": "^4.9.4" + }, + "engines": { + "node": ">=18.0.0 <19.0.0" } } diff --git a/packages/emails/src/index.tsx b/packages/emails/src/index.tsx index 912ea41b9..9ec1cd2db 100644 --- a/packages/emails/src/index.tsx +++ b/packages/emails/src/index.tsx @@ -5,6 +5,7 @@ import MagicLinkEmail from "../emails/magic-link"; import ConnectIntegration from "../emails/connect-integration"; import WorkflowFailed from "../emails/workflow-failed"; import WorkflowIntegration from "../emails/workflow-integration"; +import InviteEmail, { InviteEmailSchema } from "../emails/invite"; import { Resend } from "resend"; import { z } from "zod"; @@ -14,13 +15,13 @@ export const DeliverEmailSchema = z .discriminatedUnion("email", [ z.object({ email: z.literal("welcome"), - name: z.string().optional(), }), z.object({ email: z.literal("magic_link"), magicLink: z.string().url(), }), + InviteEmailSchema, z.object({ email: z.literal("connect_integration"), workflowId: z.string(), @@ -82,6 +83,11 @@ export class EmailClient { subject: "Magic sign-in link for Trigger.dev", component: , }; + case "invite": + return { + subject: `You've been invited to join ${data.orgName} on Trigger.dev`, + component: , + }; case "connect_integration": return { subject: `Action required: you need to connect to ${data.integration}`, diff --git a/packages/emails/tsconfig.json b/packages/emails/tsconfig.json index bc2b37125..cc08919d2 100644 --- a/packages/emails/tsconfig.json +++ b/packages/emails/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@trigger.dev/tsconfig/node16.json", + "extends": "@trigger.dev/tsconfig/node18.json", "include": ["src/globals.d.ts", "./src/**/*.ts", "./src/**/*.tsx"], "compilerOptions": { "jsx": "react",