A lot of work on email invites
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
+8
@@ -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";
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
Reference in New Issue
Block a user