Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
104 changes: 104 additions & 0 deletions app/api/organization/invites/worker/route.ts
Original file line number Diff line number Diff line change
@@ -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: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>You&apos;re invited to join ${organization.name}!</h2>
<p>${user.name} (${user.email}) has invited you to join their organization on Gumboard.</p>
<p>Click the link below to accept the invitation:</p>
<a
href="${env.BASE_URL}/invite/accept?token=${invite.id}"
style="
background-color: #007bff;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
display: inline-block;
"
>
Accept Invitation
</a>
<p style="margin-top: 20px; color: #666;">
If you don&apos;t want to receive these emails, please ignore this message.
</p>
</div>
`,
}));

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);
};
92 changes: 36 additions & 56 deletions app/setup/organization/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>You&apos;re invited to join ${orgName}!</h2>
<p>${session.user.name} (${session.user.email}) has invited you to join their organization on Gumboard.</p>
<p>Click the link below to accept the invitation:</p>
<a href="${baseUrl}/invite/accept?token=${invite.id}"
style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Accept Invitation
</a>
<p style="margin-top: 20px; color: #666;">
If you don&apos;t want to receive these emails, please ignore this message.
</p>
</div>
`,
});
} 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();
Expand Down
5 changes: 5 additions & 0 deletions lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
2 changes: 2 additions & 0 deletions lib/qstash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { Client } from "@upstash/qstash";
export const client = new Client({ token: process.env.QSTASH_TOKEN! });
36 changes: 36 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down