A lot of work on email invites

This commit is contained in:
Matt Aitken
2023-06-01 19:12:58 +01:00
parent bfaf75c8dd
commit b3ccfeb015
17 changed files with 309 additions and 91 deletions
+2 -2
View File
@@ -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;
}
+104
View File
@@ -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,
},
});
}
@@ -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,
},
});
}
@@ -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`
);
@@ -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);
+2 -3
View File
@@ -55,11 +55,10 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => {
};
export default function App() {
const location = useLocation();
const { impersonationId } = useTypedLoaderData<typeof loader>();
const isOrgChildPage = useIsProjectChildPage();
const isProjectChildPage = useIsProjectChildPage();
const showBackgroundGradient = !isOrgChildPage;
const showBackgroundGradient = !isProjectChildPage;
return (
<>
+9 -1
View File
@@ -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);
}
+5 -7
View File
@@ -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: {
+4
View File
@@ -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)}`;
@@ -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;
@@ -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");
@@ -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";
+20
View File
@@ -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
+68
View File
@@ -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<typeof InviteEmailSchema>) {
return (
<Html>
<Head />
<Preview>{`You've been invited to ${orgName}`}</Preview>
<Section style={main}>
<Container style={container}>
<Text style={h1}>{`You've been invited to ${orgName}`}</Text>
<Text style={paragraphLight}>
{inviterName ?? inviterEmail} has invited you to join their
organization on Trigger.dev.
</Text>
<Link
href={inviteLink}
target="_blank"
style={{
...anchor,
display: "block",
marginBottom: "16px",
}}
>
Click here to view the invitation
</Link>
<Image
path="/emails/logo-mono.png"
width="156"
height="28"
alt="Trigger.dev"
/>
<Footer />
</Container>
</Section>
</Html>
);
}
+6 -1
View File
@@ -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"
}
}
+7 -1
View File
@@ -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: <MagicLinkEmail magicLink={data.magicLink} />,
};
case "invite":
return {
subject: `You've been invited to join ${data.orgName} on Trigger.dev`,
component: <InviteEmail {...data} />,
};
case "connect_integration":
return {
subject: `Action required: you need to connect to ${data.integration}`,
+1 -1
View File
@@ -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",