diff --git a/.env.example b/.env.example index 08ac10b6a..a81b670a0 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,9 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/gumboard GOOGLE_CLIENT_ID=your_google-client_id GOOGLE_CLIENT_SECRET=your_google-client_secret GITHUB_CLIENT_ID=your_github_client_id -GITHUB_CLIENT_SECRET=your_github_client_secret \ No newline at end of file +GITHUB_CLIENT_SECRET=your_github_client_secret +QSTASH_URL=https://qstash.upstash.io +QSTASH_TOKEN=your-qstash-token +QSTASH_CURRENT_SIGNING_KEY=your-current-signing-key +QSTASH_NEXT_SIGNING_KEY=your-next-signing-key +BASE_URL=http://localhost:3000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89db765f3..d309a92fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gumboard_test EMAIL_FROM: noreply@example.com AUTH_RESEND_KEY: dummy-resend-key - + BASE_URL: https://gumboard.com steps: - uses: actions/checkout@v4 diff --git a/app/api/organization/invites/worker/route.ts b/app/api/organization/invites/worker/route.ts new file mode 100644 index 000000000..4c1f89ffe --- /dev/null +++ b/app/api/organization/invites/worker/route.ts @@ -0,0 +1,104 @@ +import "server-only"; + +import { db } from "@/lib/db"; +import { NextResponse } from "next/server"; +import { verifySignatureAppRouter } from "@upstash/qstash/nextjs"; +import { env } from "@/lib/env"; +import { Resend } from "resend"; + +const resend = new Resend(env.AUTH_RESEND_KEY); + +/** + * Actual business logic (NO QStash verification here) + * This function is SAFE to import at build time + */ +async function handler(request: Request) { + try { + const { organization, user, emails } = await request.json(); + + if (!Array.isArray(emails) || emails.length === 0) { + return NextResponse.json( + { + message: "please send emails in array", + success: false, + }, + { status: 400 } + ); + } + + const invites = await db.organizationInvite.createManyAndReturn({ + data: emails.map((email: string) => ({ + email, + organizationId: organization.id, + invitedBy: user.id, + })), + skipDuplicates: true, + }); + + if (invites.length === 0) { + return NextResponse.json( + { success: true, message: "no new invites created" }, + { status: 200 } + ); + } + + const batch = invites.map((invite) => ({ + from: env.EMAIL_FROM, + to: invite.email, + subject: `${user.name} invited you to join ${organization.name}`, + html: ` +
+

You're invited to join ${organization.name}!

+

${user.name} (${user.email}) has invited you to join their organization on Gumboard.

+

Click the link below to accept the invitation:

+ + Accept Invitation + +

+ If you don't want to receive these emails, please ignore this message. +

+
+ `, + })); + + await resend.batch.send(batch); + + return NextResponse.json( + { + success: true, + message: "organization invitations sent", + }, + { status: 200 } + ); + } catch (error) { + console.error("error in qstash worker", error); + + return NextResponse.json( + { + message: "qstash worker failed", + success: false, + }, + { status: 500 } + ); + } +} + +/** + * ✅ IMPORTANT PART + * QStash verification is done lazily at runtime + * NOT at build time + */ +export const POST = async (request: Request) => { + const verifiedHandler = verifySignatureAppRouter(handler); + return verifiedHandler(request); +}; diff --git a/app/setup/organization/page.tsx b/app/setup/organization/page.tsx index 0751efa1a..b1027c0fc 100644 --- a/app/setup/organization/page.tsx +++ b/app/setup/organization/page.tsx @@ -2,79 +2,59 @@ import { auth } from "@/auth"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { redirect } from "next/navigation"; import { db } from "@/lib/db"; -import { Resend } from "resend"; +// import { Resend } from "resend"; import OrganizationSetupForm from "./form"; import { env } from "@/lib/env"; -import { headers } from "next/headers"; -import { getBaseUrl } from "@/lib/utils"; +// import { headers } from "next/headers"; +// import { getBaseUrl } from "@/lib/utils"; +import { client } from "@/lib/qstash"; -const resend = new Resend(env.AUTH_RESEND_KEY); +// const resend = new Resend(env.AUTH_RESEND_KEY); -async function createOrganization(orgName: string, teamEmails: string[]) { +const createOrganization = async (orgName: string, teamEmails: string[]) => { "use server"; - const baseUrl = getBaseUrl(await headers()); const session = await auth(); - if (!session?.user?.id) { + if (!session || !session.user?.id) { throw new Error("Not authenticated"); } if (!orgName?.trim()) { throw new Error("Organization name is required"); } + const userId = session.user.id; + const uniqueEmails = [...new Set(teamEmails.map((e) => e.trim().toLowerCase()).filter(Boolean))]; - const organization = await db.organization.create({ - data: { - name: orgName.trim(), - }, - }); + const organization = await db.$transaction(async (tx) => { + const org = await tx.organization.create({ + data: { name: orgName.trim() }, + }); - await db.user.update({ - where: { id: session.user.id }, - data: { - organizationId: organization.id, - isAdmin: true, - }, + await tx.user.update({ + where: { id: userId }, + data: { + organizationId: org.id, + isAdmin: true, + }, + }); + return org; }); - if (teamEmails.length > 0) { - for (const email of teamEmails) { - try { - const invite = await db.organizationInvite.create({ - data: { - email, - organizationId: organization.id, - invitedBy: session.user.id!, - }, - }); - - await resend.emails.send({ - from: env.EMAIL_FROM!, - to: email, - subject: `${session.user.name} invited you to join ${orgName}`, - html: ` -
-

You're invited to join ${orgName}!

-

${session.user.name} (${session.user.email}) has invited you to join their organization on Gumboard.

-

Click the link below to accept the invitation:

- - Accept Invitation - -

- If you don't want to receive these emails, please ignore this message. -

-
- `, - }); - } catch (error) { - console.error(`Failed to send invite to ${email}:`, error); - } - } - } - - return { success: true, organization }; -} + client + .publishJSON({ + url: `${env.BASE_URL}/api/organization/invites/worker`, + body: { + organization, + user: session.user, + emails: uniqueEmails, + }, + }) + .catch(console.error); + return { + success: true, + organization, + }; +}; export default async function OrganizationSetup() { const session = await auth(); diff --git a/lib/env.ts b/lib/env.ts index 19ccd9ce5..020a4c611 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -19,6 +19,11 @@ const schema = z.object({ // NextAuth AUTH_URL: z.string().optional(), AUTH_SECRET: z.string(), + BASE_URL: z.string().optional(), + QSTASH_URL: z.string().optional(), + QSTASH_TOKEN: z.string().optional(), + QSTASH_CURRENT_SIGNING_KEY: z.string().optional(), + QSTASH_NEXT_SIGNING_KEY: z.string().optional(), }); export const env = schema.parse(process.env); diff --git a/lib/qstash.ts b/lib/qstash.ts new file mode 100644 index 000000000..06eb1830d --- /dev/null +++ b/lib/qstash.ts @@ -0,0 +1,2 @@ +import { Client } from "@upstash/qstash"; +export const client = new Client({ token: process.env.QSTASH_TOKEN! }); diff --git a/package-lock.json b/package-lock.json index 3f2abab08..439194b30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tooltip": "^1.2.8", + "@upstash/qstash": "^2.9.0", "@vercel/otel": "^1.13.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -4748,6 +4749,26 @@ "win32" ] }, + "node_modules/@upstash/qstash": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@upstash/qstash/-/qstash-2.9.0.tgz", + "integrity": "sha512-RFvWB98ot5SXUZFIV/Av6hYdS+yu700kg+azUaJqV/fqgylUrWkYnCTOE4DJgdMHUEb0l7tH2Xhl70Zd4Q4zHw==", + "license": "MIT", + "dependencies": { + "crypto-js": ">=4.2.0", + "jose": "^5.2.3", + "neverthrow": "^7.0.1" + } + }, + "node_modules/@upstash/qstash/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@vercel/otel": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/@vercel/otel/-/otel-1.13.0.tgz", @@ -5626,6 +5647,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -9483,6 +9510,15 @@ "dev": true, "license": "MIT" }, + "node_modules/neverthrow": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/neverthrow/-/neverthrow-7.2.0.tgz", + "integrity": "sha512-iGBUfFB7yPczHHtA8dksKTJ9E8TESNTAx1UQWW6TzMF280vo9jdPYpLUXrMN1BCkPdHFdNG3fxOt2CUad8KhAw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/next": { "version": "15.5.7", "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", diff --git a/package.json b/package.json index 0622da853..9b125a44c 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tooltip": "^1.2.8", + "@upstash/qstash": "^2.9.0", "@vercel/otel": "^1.13.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1",