Skip to content
Merged
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
73 changes: 73 additions & 0 deletions .github/workflows/migrate-tenants.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Migrate Tenant Databases
permissions:
contents: read

on:
push:
branches:
- main
- release
paths:
- 'drizzle/**'
- 'ops/drizzle/**'
workflow_dispatch:
inputs:
environment:
description: 'Environment to migrate'
required: true
type: choice
options:
- staging
- prod

jobs:
migrate-staging:
name: Run Staging Migrations
runs-on: ubuntu-latest
timeout-minutes: 30
environment: staging
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'staging')

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Run tenant migrations (Staging)
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DATABASE_SSL: ${{ secrets.DATABASE_SSL }}
run: bun run migrate:tenants

migrate-prod:
name: Run Prod Migrations
runs-on: ubuntu-latest
timeout-minutes: 30
environment: prod
if: (github.event_name == 'push' && github.ref == 'refs/heads/release') || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'prod')

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Run tenant migrations (prod)
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DATABASE_SSL: ${{ secrets.DATABASE_SSL }}
run: bun run migrate:tenants

2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v20
v22
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ bun run build # Build for production
bun start # Start production server
bun run lint # Run Biome linter
bun run generate:migrations # Generate Drizzle migrations
bun run migrate:tenants # Migrate all tenant databases (CI/CD - IDs are masked in logs)
```

**Security Note:** The `migrate:tenants` script masks all tenant IDs in logs for privacy protection since GitHub Actions logs are public.

## Project Architecture

### Tech Stack
Expand Down
222 changes: 219 additions & 3 deletions app/(api)/api/webhook/auth/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import path from "node:path";
import { verifyWebhook } from "@clerk/nextjs/webhooks";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import type { NextRequest } from "next/server";
import { Resend } from "resend";
import {
AccountDeleted,
accountDeletedPlainText,
} from "@/components/emails/account-deleted";
import * as schema from "@/drizzle/schema";
import { SearchService } from "@/lib/search";
import { deleteDatabase } from "@/lib/utils/useDatabase";
import {
deleteDatabase,
getDatabaseForOwner,
getDatabaseName,
} from "@/lib/utils/useDatabase";
import { addUserToTenantDb } from "@/lib/utils/useUser";
import { triggerBlobDeletionWorkflow } from "@/lib/utils/workflow";
import { opsOrganization, opsUser } from "@/ops/drizzle/schema";
import { getOpsDatabase } from "@/ops/useOps";
import { addUserToOpsDb, getOpsDatabase } from "@/ops/useOps";

type ClerkOrgData = {
createdBy?: {
Expand All @@ -25,11 +34,45 @@ enum WebhookEventType {
organizationCreated = "organization.created",
organizationDeleted = "organization.deleted",
organizationUpdated = "organization.updated",
organizationInvitationAccepted = "organizationInvitation.accepted",
userCreated = "user.created",
userDeleted = "user.deleted",
userUpdated = "user.updated",
}

async function createTenantDatabase(ownerId: string): Promise<void> {
const databaseName = getDatabaseName(ownerId).match(
(value) => value,
() => {
throw new Error("Database name not found");
},
);

const sslMode = process.env.DATABASE_SSL === "true" ? "?sslmode=require" : "";

const ownerDb = drizzle(`${process.env.DATABASE_URL}/manage${sslMode}`, {
schema,
});

const checkDb = await ownerDb.execute(
sql`SELECT 1 FROM pg_database WHERE datname = ${databaseName}`,
);

if (checkDb.rowCount === 0) {
await ownerDb.execute(sql`CREATE DATABASE ${sql.identifier(databaseName)}`);
console.log(`Created database for tenant: ${databaseName}`);
}

const tenantDb = drizzle(
`${process.env.DATABASE_URL}/${databaseName}${sslMode}`,
{ schema },
);

const migrationsFolder = path.resolve(process.cwd(), "drizzle");
await migrate(tenantDb, { migrationsFolder });
console.log(`Migrated database for tenant: ${databaseName}`);
}

export async function POST(req: NextRequest) {
try {
const evt = await verifyWebhook(req);
Expand All @@ -44,6 +87,179 @@ export async function POST(req: NextRequest) {
}

switch (eventType) {
case WebhookEventType.userCreated:
try {
const userData = evt.data;
await createTenantDatabase(id);
await Promise.all([
addUserToTenantDb(userData),
addUserToOpsDb(userData),
]);
console.log("User created - database and data synced successfully");
} catch (err) {
console.error("Error creating user and database:", err);
}
break;
case WebhookEventType.userUpdated:
try {
const userData = evt.data;
await Promise.all([
addUserToTenantDb(userData),
addUserToOpsDb(userData),
]);
console.log("User updated - data synced successfully");
} catch (err) {
console.error("Error syncing user data:", err);
}
break;
case WebhookEventType.organizationCreated:
try {
const orgData = evt.data;
await createTenantDatabase(id);
const db = await getOpsDatabase();
await db
.insert(opsOrganization)
.values({
id: orgData.id,
name: orgData.name,
rawData: orgData,
lastActiveAt: new Date(),
})
.execute();

if (orgData.created_by) {
try {
const creatorData = await db
.select()
.from(opsUser)
.where(eq(opsUser.id, orgData.created_by))
.limit(1);

if (creatorData.length > 0) {
const creator = creatorData[0];
const orgDb = await getDatabaseForOwner(id);
await orgDb
.insert(schema.user)
.values({
id: creator.id,
email: creator.email,
firstName: creator.firstName,
lastName: creator.lastName,
imageUrl: creator.imageUrl,
rawData: creator.rawData,
lastActiveAt: new Date(),
})
.onConflictDoUpdate({
target: schema.user.id,
set: {
email: creator.email,
firstName: creator.firstName,
lastName: creator.lastName,
imageUrl: creator.imageUrl,
rawData: creator.rawData,
lastActiveAt: new Date(),
},
})
.execute();
console.log(
`Added creator ${creator.id} to organization database`,
);
}
} catch (creatorErr) {
console.error(
"Error adding creator to org database:",
creatorErr,
);
}
}

console.log(
"Organization created - database and data synced successfully",
);
} catch (err) {
console.error("Error creating organization and database:", err);
}
break;
case WebhookEventType.organizationUpdated:
try {
const orgData = evt.data;
const db = await getOpsDatabase();
await db
.insert(opsOrganization)
.values({
id: orgData.id,
name: orgData.name,
rawData: orgData,
lastActiveAt: new Date(),
})
.onConflictDoUpdate({
target: opsOrganization.id,
set: {
name: orgData.name,
rawData: orgData,
lastActiveAt: new Date(),
markedForDeletionAt: null,
finalWarningAt: null,
},
})
.execute();
console.log("Organization updated - data synced successfully");
} catch (err) {
console.error("Error syncing org data:", err);
}
break;
case WebhookEventType.organizationInvitationAccepted:
try {
const invitationData = evt.data;
const orgId = invitationData.organization_id;
const emailAddress = invitationData.email_address;

if (!orgId || !emailAddress) {
console.error("Missing organization or email in invitation data");
break;
}

const db = await getOpsDatabase();
const userData = await db
.select()
.from(opsUser)
.where(eq(opsUser.email, emailAddress))
.limit(1);

if (userData.length > 0) {
const user = userData[0];
const orgDb = await getDatabaseForOwner(orgId);
await orgDb
.insert(schema.user)
.values({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
imageUrl: user.imageUrl,
rawData: user.rawData,
lastActiveAt: new Date(),
})
.onConflictDoUpdate({
target: schema.user.id,
set: {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
imageUrl: user.imageUrl,
rawData: user.rawData,
lastActiveAt: new Date(),
},
})
.execute();
console.log(
`Added user ${user.id} to organization ${orgId} database after invitation acceptance`,
);
}
} catch (err) {
console.error("Error adding user to org after invitation:", err);
}
break;
case WebhookEventType.userDeleted:
// For individual users, delete database immediately
// This happens when a user without an organization deletes their account
Expand Down
15 changes: 3 additions & 12 deletions app/(dashboard)/[tenant]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { redirect } from "next/navigation";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { ClientRedirect } from "@/components/core/client-redirect";
import { ReportTimezone } from "@/components/core/report-timezone";
import { Navbar } from "@/components/layout/navbar";
import { isDatabaseReady } from "@/lib/utils/useDatabase";
import { getOwner } from "@/lib/utils/useOwner";
import { TRPCReactProvider } from "@/trpc/client";
import { caller } from "@/trpc/server";
Expand All @@ -16,18 +15,10 @@ export default async function ConsoleLayout(props: {
const { tenant } = await props.params;
const { orgSlug } = await getOwner();
if (tenant !== orgSlug) {
redirect("/start");
return <ClientRedirect path="/start" />;
}

// Parallelize database ready check and notifications wire setup
const [ready, notificationsWire] = await Promise.all([
isDatabaseReady(),
caller.user.getNotificationsWire(),
]);

if (!ready) {
redirect("/start");
}
const notificationsWire = await caller.user.getNotificationsWire();

return (
<TRPCReactProvider>
Expand Down
4 changes: 2 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ClerkProvider } from "@clerk/nextjs";
import { Geist } from "next/font/google";
import { PostHogProvider } from "@/components/core/posthog-provider";
import { ThemeProvider } from "@/components/core/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { SITE_METADATA } from "@/data/marketing";
import { cn } from "@/lib/utils";
import { ClerkProvider } from "@clerk/nextjs";
import { Geist } from "next/font/google";
import "./globals.css";

const mainFont = Geist({
Expand Down
2 changes: 1 addition & 1 deletion app/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Spinner } from "@/components/core/loaders";
import Image from "next/image";
import { Spinner } from "@/components/core/loaders";
import logo from "../public/images/logo.png";

export default function Loading() {
Expand Down
Loading