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",