From 12a423b1fb686b2671e29431dc3eafdd7bb2e868 Mon Sep 17 00:00:00 2001 From: Vinodbiradar09 Date: Mon, 9 Feb 2026 20:00:26 +0530 Subject: [PATCH 1/6] perf(org-invites): move invite emails to background worker --- app/api/organization/invites/worker/route.ts | 59 ++++++++++++ app/setup/organization/page.tsx | 94 ++++++++------------ lib/env.ts | 5 ++ lib/qstash.ts | 3 + package-lock.json | 36 ++++++++ package.json | 1 + 6 files changed, 143 insertions(+), 55 deletions(-) create mode 100644 app/api/organization/invites/worker/route.ts create mode 100644 lib/qstash.ts diff --git a/app/api/organization/invites/worker/route.ts b/app/api/organization/invites/worker/route.ts new file mode 100644 index 000000000..626937118 --- /dev/null +++ b/app/api/organization/invites/worker/route.ts @@ -0,0 +1,59 @@ +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); + +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, + }); + } + const invites = await db.organizationInvite.createManyAndReturn({ + data : emails.map((email : string) => ({ + email, + organizationId : organization.id, + invitedBy : user.id, + })), + skipDuplicates : true, + }); + 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 Board.

+

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 : "the organization invitation has been sent"}, {status : 200}); + } catch (error) { + console.log("error in qstash queue", error); + return NextResponse.json( + { + message: "qstash server failed", + success: false, + }, + { status: 500 }, + ); + } +} + +export const POST = verifySignatureAppRouter(handler); \ No newline at end of file diff --git a/app/setup/organization/page.tsx b/app/setup/organization/page.tsx index 0751efa1a..d41c483b3 100644 --- a/app/setup/organization/page.tsx +++ b/app/setup/organization/page.tsx @@ -2,79 +2,63 @@ 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); - } + client.publishJSON({ + url : `${env.BASE_URL}/api/organization/invites/worker`, + body : { + organization, + user : session.user, + emails : uniqueEmails, } - } - - return { success: true, organization }; -} + }).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..95a493455 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(), + QSTASH_URL : z.string(), + QSTASH_TOKEN : z.string(), + QSTASH_CURRENT_SIGNING_KEY : z.string(), + QSTASH_NEXT_SIGNING_KEY : z.string(), }); export const env = schema.parse(process.env); diff --git a/lib/qstash.ts b/lib/qstash.ts new file mode 100644 index 000000000..eabdaee77 --- /dev/null +++ b/lib/qstash.ts @@ -0,0 +1,3 @@ +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", From d4bfa841c42c12cc7b94c67fe17ce91d9dcab1b9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:36:48 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- app/api/organization/invites/worker/route.ts | 71 ++++++++++---------- app/setup/organization/page.tsx | 26 +++---- lib/env.ts | 10 +-- lib/qstash.ts | 3 +- 4 files changed, 54 insertions(+), 56 deletions(-) diff --git a/app/api/organization/invites/worker/route.ts b/app/api/organization/invites/worker/route.ts index 626937118..f0883b750 100644 --- a/app/api/organization/invites/worker/route.ts +++ b/app/api/organization/invites/worker/route.ts @@ -6,28 +6,28 @@ import { Resend } from "resend"; const resend = new Resend(env.AUTH_RESEND_KEY); -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, - }); - } - const invites = await db.organizationInvite.createManyAndReturn({ - data : emails.map((email : string) => ({ - email, - organizationId : organization.id, - invitedBy : user.id, - })), - skipDuplicates : true, - }); - const batch = invites.map(( invite )=>({ - from : env.EMAIL_FROM, - to : invite.email, - subject : `${user.name} invited you to join ${organization.name}`, - html: ` +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, + }); + } + const invites = await db.organizationInvite.createManyAndReturn({ + data: emails.map((email: string) => ({ + email, + organizationId: organization.id, + invitedBy: user.id, + })), + skipDuplicates: true, + }); + 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 Board.

@@ -41,19 +41,22 @@ async function handler(request : Request) {

`, - })) - await resend.batch.send(batch); - return NextResponse.json({ success: true , message : "the organization invitation has been sent"}, {status : 200}); - } catch (error) { - console.log("error in qstash queue", error); - return NextResponse.json( - { - message: "qstash server failed", - success: false, - }, - { status: 500 }, + })); + await resend.batch.send(batch); + return NextResponse.json( + { success: true, message: "the organization invitation has been sent" }, + { status: 200 } + ); + } catch (error) { + console.log("error in qstash queue", error); + return NextResponse.json( + { + message: "qstash server failed", + success: false, + }, + { status: 500 } ); } } -export const POST = verifySignatureAppRouter(handler); \ No newline at end of file +export const POST = verifySignatureAppRouter(handler); diff --git a/app/setup/organization/page.tsx b/app/setup/organization/page.tsx index d41c483b3..b1027c0fc 100644 --- a/app/setup/organization/page.tsx +++ b/app/setup/organization/page.tsx @@ -23,13 +23,7 @@ const createOrganization = async (orgName: string, teamEmails: string[]) => { 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 uniqueEmails = [...new Set(teamEmails.map((e) => e.trim().toLowerCase()).filter(Boolean))]; const organization = await db.$transaction(async (tx) => { const org = await tx.organization.create({ @@ -46,14 +40,16 @@ const createOrganization = async (orgName: string, teamEmails: string[]) => { return org; }); - client.publishJSON({ - url : `${env.BASE_URL}/api/organization/invites/worker`, - body : { - organization, - user : session.user, - emails : uniqueEmails, - } - }).catch(console.error); + client + .publishJSON({ + url: `${env.BASE_URL}/api/organization/invites/worker`, + body: { + organization, + user: session.user, + emails: uniqueEmails, + }, + }) + .catch(console.error); return { success: true, organization, diff --git a/lib/env.ts b/lib/env.ts index 95a493455..654dca197 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -19,11 +19,11 @@ const schema = z.object({ // NextAuth AUTH_URL: z.string().optional(), AUTH_SECRET: z.string(), - BASE_URL : z.string(), - QSTASH_URL : z.string(), - QSTASH_TOKEN : z.string(), - QSTASH_CURRENT_SIGNING_KEY : z.string(), - QSTASH_NEXT_SIGNING_KEY : z.string(), + BASE_URL: z.string(), + QSTASH_URL: z.string(), + QSTASH_TOKEN: z.string(), + QSTASH_CURRENT_SIGNING_KEY: z.string(), + QSTASH_NEXT_SIGNING_KEY: z.string(), }); export const env = schema.parse(process.env); diff --git a/lib/qstash.ts b/lib/qstash.ts index eabdaee77..06eb1830d 100644 --- a/lib/qstash.ts +++ b/lib/qstash.ts @@ -1,3 +1,2 @@ import { Client } from "@upstash/qstash"; -export const client = new Client({ token : process.env.QSTASH_TOKEN!}); - +export const client = new Client({ token: process.env.QSTASH_TOKEN! }); From 1a7104551b632406d8352f364af523f99d6e7807 Mon Sep 17 00:00:00 2001 From: Vinodbiradar09 Date: Tue, 10 Feb 2026 08:54:00 +0530 Subject: [PATCH 3/6] ci: add BASE_URL and document qstash env vars --- .env.example | 7 ++++++- .github/workflows/ci.yml | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) 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 From 93650b505a1febdf6427d6ddbde1ddb1510a2fc7 Mon Sep 17 00:00:00 2001 From: Vinodbiradar09 Date: Tue, 10 Feb 2026 09:56:51 +0530 Subject: [PATCH 4/6] perf(org-invites): move organization invite emails to background worker --- lib/env.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/env.ts b/lib/env.ts index 654dca197..2b7abc091 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -20,10 +20,10 @@ const schema = z.object({ AUTH_URL: z.string().optional(), AUTH_SECRET: z.string(), BASE_URL: z.string(), - QSTASH_URL: z.string(), - QSTASH_TOKEN: z.string(), - QSTASH_CURRENT_SIGNING_KEY: z.string(), - QSTASH_NEXT_SIGNING_KEY: z.string(), + 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); From 6dabb6373610da99942ff4f5b85c8f4c379b47ff Mon Sep 17 00:00:00 2001 From: Vinodbiradar09 Date: Tue, 10 Feb 2026 10:07:05 +0530 Subject: [PATCH 5/6] fix(env): make BASE_URL optional to avoid build-time failure --- lib/env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/env.ts b/lib/env.ts index 2b7abc091..020a4c611 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -19,7 +19,7 @@ const schema = z.object({ // NextAuth AUTH_URL: z.string().optional(), AUTH_SECRET: z.string(), - BASE_URL: z.string(), + BASE_URL: z.string().optional(), QSTASH_URL: z.string().optional(), QSTASH_TOKEN: z.string().optional(), QSTASH_CURRENT_SIGNING_KEY: z.string().optional(), From be5bb48169afcb7cb27b67e6062e09441aca7ebf Mon Sep 17 00:00:00 2001 From: Vinodbiradar09 Date: Tue, 10 Feb 2026 10:15:10 +0530 Subject: [PATCH 6/6] fix(qstash): defer signature verification to runtime to avoid build-time failure --- app/api/organization/invites/worker/route.ts | 84 +++++++++++++++----- 1 file changed, 63 insertions(+), 21 deletions(-) diff --git a/app/api/organization/invites/worker/route.ts b/app/api/organization/invites/worker/route.ts index f0883b750..4c1f89ffe 100644 --- a/app/api/organization/invites/worker/route.ts +++ b/app/api/organization/invites/worker/route.ts @@ -1,3 +1,5 @@ +import "server-only"; + import { db } from "@/lib/db"; import { NextResponse } from "next/server"; import { verifySignatureAppRouter } from "@upstash/qstash/nextjs"; @@ -6,15 +8,24 @@ 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, - }); + 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, @@ -23,35 +34,58 @@ async function handler(request: Request) { })), 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 Board.

-

Click the link below to accept the invitation:

- - Accept Invitation - -

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

-
- `, +
+

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: "the organization invitation has been sent" }, + { + success: true, + message: "organization invitations sent", + }, { status: 200 } ); } catch (error) { - console.log("error in qstash queue", error); + console.error("error in qstash worker", error); + return NextResponse.json( { - message: "qstash server failed", + message: "qstash worker failed", success: false, }, { status: 500 } @@ -59,4 +93,12 @@ async function handler(request: Request) { } } -export const POST = verifySignatureAppRouter(handler); +/** + * ✅ 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); +};