From 2be1c10ede6e566c99ab93f82352fee42aa0021b Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Tue, 14 Oct 2025 22:46:58 +1100 Subject: [PATCH 1/8] Add migration scripts --- .github/workflows/migrate-tenants.yml | 71 +++++++++++++ .nvmrc | 2 +- lib/utils/useDatabase.ts | 41 +------- package.json | 3 +- scripts/migrate-all-tenants.ts | 142 ++++++++++++++++++++++++++ 5 files changed, 217 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/migrate-tenants.yml create mode 100644 scripts/migrate-all-tenants.ts diff --git a/.github/workflows/migrate-tenants.yml b/.github/workflows/migrate-tenants.yml new file mode 100644 index 00000000..e8a9ac1a --- /dev/null +++ b/.github/workflows/migrate-tenants.yml @@ -0,0 +1,71 @@ +name: Migrate Tenant Databases + +on: + push: + branches: + - main + - release + paths: + - 'drizzle/**' + - 'ops/drizzle/**' + workflow_dispatch: + inputs: + environment: + description: 'Environment to migrate' + required: true + type: choice + options: + - dev + - prod + +jobs: + migrate-dev: + name: Run Dev Migrations + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: dev + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'dev') + + 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 (dev) + 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 + diff --git a/.nvmrc b/.nvmrc index 9a2a0e21..53d1c14d 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20 +v22 diff --git a/lib/utils/useDatabase.ts b/lib/utils/useDatabase.ts index de048f1f..ec42f379 100644 --- a/lib/utils/useDatabase.ts +++ b/lib/utils/useDatabase.ts @@ -1,10 +1,8 @@ -import path from "node:path"; import { currentUser } from "@clerk/nextjs/server"; import { sql } from "drizzle-orm"; import { upstashCache } from "drizzle-orm/cache/upstash"; import { drizzle } from "drizzle-orm/node-postgres"; -import { migrate } from "drizzle-orm/node-postgres/migrator"; -import { err, ok, type Result, ResultAsync } from "neverthrow"; +import { err, ok, type Result } from "neverthrow"; import type { Database } from "@/drizzle/types"; import { addUserToOpsDb } from "@/ops/useOps"; import * as schema from "../../drizzle/schema"; @@ -14,13 +12,6 @@ import { addUserToTenantDb } from "./useUser"; const connectionPool = new Map(); const connectionTimestamps = new Map(); -function handleError(message: string) { - return (error: unknown) => { - console.error(message, error); - return false; - }; -} - function getDatabaseName(ownerId: string): Result { if (!ownerId.startsWith("org_") && !ownerId.startsWith("user_")) { return err("Invalid owner ID"); @@ -30,12 +21,6 @@ function getDatabaseName(ownerId: string): Result { export async function isDatabaseReady(): Promise { try { - const migrationResult = await migrateDatabase(); - - if (!migrationResult) { - return false; - } - const userData = await currentUser(); if (!userData) { throw new Error("No user found"); @@ -50,30 +35,6 @@ export async function isDatabaseReady(): Promise { } } -async function migrateDatabase(): Promise { - const dbResult = await ResultAsync.fromPromise( - database(), - handleError("Failed to get database"), - ); - - if (dbResult.isErr()) { - return false; - } - - const db = dbResult.value; - const migrationsFolder = path.resolve(process.cwd(), "drizzle"); - - const migrateResult = await ResultAsync.fromPromise( - migrate(db, { migrationsFolder: migrationsFolder }), - handleError("Failed to migrate database"), - ); - - return migrateResult.match( - () => true, - () => false, - ); -} - export async function database(): Promise { const { ownerId } = await getOwner(); if (!ownerId) { diff --git a/package.json b/package.json index 53e8e480..97765ba5 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "format": "biome format --write", "lint:fix": "biome lint --write", "fix": "biome format --write && biome lint --write", - "generate:migrations": "drizzle-kit generate" + "generate:migrations": "drizzle-kit generate", + "migrate:tenants": "bun scripts/migrate-all-tenants.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.623.0", diff --git a/scripts/migrate-all-tenants.ts b/scripts/migrate-all-tenants.ts new file mode 100644 index 00000000..ce4908b6 --- /dev/null +++ b/scripts/migrate-all-tenants.ts @@ -0,0 +1,142 @@ +import path from "node:path"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { sql } from "drizzle-orm"; +import * as schema from "../drizzle/schema"; +import * as opsSchema from "../ops/drizzle/schema"; + +function getDatabaseName(ownerId: string): string { + return ownerId.toLowerCase().replace(/ /g, "_"); +} + +async function getOpsDatabase() { + const sslMode = process.env.DATABASE_SSL === "true" ? "?sslmode=require" : ""; + return drizzle(`${process.env.DATABASE_URL}/manage${sslMode}`, { + schema: opsSchema, + }); +} + +async function getAllTenantIds(): Promise { + const opsDb = await getOpsDatabase(); + + const [users, organizations] = await Promise.all([ + opsDb.select({ id: opsSchema.opsUser.id }).from(opsSchema.opsUser), + opsDb + .select({ id: opsSchema.opsOrganization.id }) + .from(opsSchema.opsOrganization), + ]); + + const tenantIds = [ + ...users.map((u) => u.id), + ...organizations.map((o) => o.id), + ]; + + return tenantIds; +} + +async function migrateTenantDatabase(ownerId: string): Promise<{ + success: boolean; + skipped?: boolean; + error?: string; +}> { + try { + const databaseName = getDatabaseName(ownerId); + 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) { + return { success: true, skipped: true }; + } + + const tenantDb = drizzle( + `${process.env.DATABASE_URL}/${databaseName}${sslMode}`, + { schema }, + ); + + const migrationsFolder = path.resolve(process.cwd(), "drizzle"); + await migrate(tenantDb, { migrationsFolder }); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function main() { + console.log("Starting tenant database migrations...\n"); + + if (!process.env.DATABASE_URL) { + console.error("ERROR: DATABASE_URL environment variable is not set"); + process.exit(1); + } + + const tenantIds = await getAllTenantIds(); + console.log(`Found ${tenantIds.length} tenant(s) to migrate\n`); + + if (tenantIds.length === 0) { + console.log("No tenants found. Nothing to migrate."); + return; + } + + let successCount = 0; + let skippedCount = 0; + let failureCount = 0; + const failures: Array<{ tenantId: string; error: string }> = []; + + for (let i = 0; i < tenantIds.length; i++) { + const tenantId = tenantIds[i]; + const progress = `[${i + 1}/${tenantIds.length}]`; + process.stdout.write(`${progress} Migrating ${tenantId}... `); + + const result = await migrateTenantDatabase(tenantId); + + if (result.success) { + if (result.skipped) { + console.log("⊘ (no database)"); + skippedCount++; + } else { + console.log("✓"); + successCount++; + } + } else { + console.log("✗"); + console.log(` Error: ${result.error}`); + failureCount++; + failures.push({ tenantId, error: result.error || "Unknown error" }); + } + } + + console.log("\n" + "=".repeat(60)); + console.log("Migration Summary:"); + console.log(` Total: ${tenantIds.length}`); + console.log(` Success: ${successCount}`); + console.log(` Skipped: ${skippedCount} (no database created yet)`); + console.log(` Failed: ${failureCount}`); + + if (failures.length > 0) { + console.log("\nFailed migrations:"); + for (const failure of failures) { + console.log(` - ${failure.tenantId}: ${failure.error}`); + } + console.log("\n✗ Some migrations failed!"); + process.exit(1); + } + + console.log("\n✓ All tenant databases migrated successfully!"); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); From f58a014a0da3c079bf0a863cd88bad44dc65dfec Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Tue, 14 Oct 2025 22:48:55 +1100 Subject: [PATCH 2/8] change env to staging --- .github/workflows/migrate-tenants.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/migrate-tenants.yml b/.github/workflows/migrate-tenants.yml index e8a9ac1a..4cc0547c 100644 --- a/.github/workflows/migrate-tenants.yml +++ b/.github/workflows/migrate-tenants.yml @@ -15,16 +15,16 @@ on: required: true type: choice options: - - dev + - staging - prod jobs: - migrate-dev: - name: Run Dev Migrations + migrate-staging: + name: Run Staging Migrations runs-on: ubuntu-latest timeout-minutes: 30 - environment: dev - if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'dev') + 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 @@ -38,7 +38,7 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Run tenant migrations (dev) + - name: Run tenant migrations (Staging) env: DATABASE_URL: ${{ secrets.DATABASE_URL }} DATABASE_SSL: ${{ secrets.DATABASE_SSL }} From 54ba3fac36713d4b2a10965dd563040f2308d996 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Tue, 14 Oct 2025 22:51:56 +1100 Subject: [PATCH 3/8] Update migration script --- CLAUDE.md | 3 +++ scripts/migrate-all-tenants.ts | 22 ++++++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 164bffd0..1d4c0ff9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/scripts/migrate-all-tenants.ts b/scripts/migrate-all-tenants.ts index ce4908b6..f16cfc14 100644 --- a/scripts/migrate-all-tenants.ts +++ b/scripts/migrate-all-tenants.ts @@ -9,6 +9,16 @@ function getDatabaseName(ownerId: string): string { return ownerId.toLowerCase().replace(/ /g, "_"); } +function maskId(id: string): string { + if (id.length <= 8) return "***"; + return `${id.slice(0, 4)}...${id.slice(-4)}`; +} + +function sanitizeError(error: string, tenantId: string): string { + const databaseName = getDatabaseName(tenantId); + return error.replace(new RegExp(tenantId, "g"), maskId(tenantId)).replace(new RegExp(databaseName, "g"), "tenant_db"); +} + async function getOpsDatabase() { const sslMode = process.env.DATABASE_SSL === "true" ? "?sslmode=require" : ""; return drizzle(`${process.env.DATABASE_URL}/manage${sslMode}`, { @@ -96,8 +106,9 @@ async function main() { for (let i = 0; i < tenantIds.length; i++) { const tenantId = tenantIds[i]; + const maskedId = maskId(tenantId); const progress = `[${i + 1}/${tenantIds.length}]`; - process.stdout.write(`${progress} Migrating ${tenantId}... `); + process.stdout.write(`${progress} Migrating ${maskedId}... `); const result = await migrateTenantDatabase(tenantId); @@ -111,9 +122,10 @@ async function main() { } } else { console.log("✗"); - console.log(` Error: ${result.error}`); + const sanitizedError = sanitizeError(result.error || "Unknown error", tenantId); + console.log(` Error: ${sanitizedError}`); failureCount++; - failures.push({ tenantId, error: result.error || "Unknown error" }); + failures.push({ tenantId: maskedId, error: sanitizedError }); } } @@ -137,6 +149,8 @@ async function main() { } main().catch((error) => { - console.error("Fatal error:", error); + const errorMessage = error instanceof Error ? error.message : String(error); + const sanitized = errorMessage.replace(/user_[a-zA-Z0-9]+/g, "user_***").replace(/org_[a-zA-Z0-9]+/g, "org_***"); + console.error("Fatal error:", sanitized); process.exit(1); }); From 16a016b22d91106351a7dbe2e10517bc1b9a61dd Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 15 Oct 2025 18:06:38 +1100 Subject: [PATCH 4/8] Remove on demand db setup --- app/(api)/api/webhook/auth/route.ts | 118 +++++++++++++++++++++++++++- app/(dashboard)/[tenant]/layout.tsx | 15 +--- app/layout.tsx | 4 +- app/loading.tsx | 2 +- app/page.tsx | 6 +- app/start/page.tsx | 33 +------- bun.lock | 50 ++++++------ components/core/client-redirect.tsx | 13 +++ components/layout/navbar.tsx | 12 +-- lib/utils/useDatabase.ts | 55 +------------ lib/utils/useUser.ts | 24 +++--- ops/useOps.ts | 52 +++--------- package.json | 16 ++-- trpc/query-client.ts | 4 +- 14 files changed, 203 insertions(+), 201 deletions(-) create mode 100644 components/core/client-redirect.tsx diff --git a/app/(api)/api/webhook/auth/route.ts b/app/(api)/api/webhook/auth/route.ts index 283df25c..bc63bdc5 100644 --- a/app/(api)/api/webhook/auth/route.ts +++ b/app/(api)/api/webhook/auth/route.ts @@ -1,16 +1,21 @@ +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, 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?: { @@ -30,6 +35,39 @@ enum WebhookEventType { userUpdated = "user.updated", } +async function createTenantDatabase(ownerId: string): Promise { + 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); @@ -44,6 +82,80 @@ 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(); + 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.userDeleted: // For individual users, delete database immediately // This happens when a user without an organization deletes their account diff --git a/app/(dashboard)/[tenant]/layout.tsx b/app/(dashboard)/[tenant]/layout.tsx index b3b762ae..50ac4c63 100644 --- a/app/(dashboard)/[tenant]/layout.tsx +++ b/app/(dashboard)/[tenant]/layout.tsx @@ -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"; @@ -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 ; } - // 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 ( diff --git a/app/layout.tsx b/app/layout.tsx index 8ee4b013..337b2868 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ diff --git a/app/loading.tsx b/app/loading.tsx index 0d9a4356..b39dc850 100644 --- a/app/loading.tsx +++ b/app/loading.tsx @@ -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() { diff --git a/app/page.tsx b/app/page.tsx index 517508e5..ccd651b8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,16 +1,16 @@ import { auth } from "@clerk/nextjs/server"; import Image from "next/image"; import Link from "next/link"; -import { redirect } from "next/navigation"; +import { ClientRedirect } from "@/components/core/client-redirect"; import { Footer } from "@/components/layout/footer"; import { Header } from "@/components/layout/header"; export default async function Home() { const { userId } = await auth(); - if (userId) { - redirect("/start"); + return ; } + return (
diff --git a/app/start/page.tsx b/app/start/page.tsx index 7d87b3d8..600103d1 100644 --- a/app/start/page.tsx +++ b/app/start/page.tsx @@ -1,38 +1,13 @@ import { auth } from "@clerk/nextjs/server"; -import { RedirectType, redirect } from "next/navigation"; -import { isDatabaseReady } from "@/lib/utils/useDatabase"; +import { ClientRedirect } from "@/components/core/client-redirect"; export const fetchCache = "force-no-store"; export const dynamic = "force-dynamic"; -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - export default async function Start() { const { userId, orgSlug } = await auth(); - if (!userId) { - console.warn("No session, redirecting to sign-in"); - redirect("/sign-in"); - } - - let ready = false; - let retryCount = 0; - const maxRetries = 10; - - while (!ready && retryCount < maxRetries) { - ready = await isDatabaseReady(); - if (!ready) { - console.log( - `Database not ready, retrying (${retryCount + 1}/${maxRetries})...`, - ); - await sleep(500); - retryCount++; - } - } - - if (!ready) { - console.error("Database not ready after maximum retries"); - redirect("/error"); - } - redirect(`/${orgSlug ?? "me"}/today`, RedirectType.replace); + return ( + + ); } diff --git a/bun.lock b/bun.lock index 28fb5aaf..21de32f6 100644 --- a/bun.lock +++ b/bun.lock @@ -52,12 +52,12 @@ "dotenv": "^16.4.7", "drizzle-orm": "^0.44.5", "es-toolkit": "^1.39.8", - "eslint-config-next": "15.5.1", + "eslint-config-next": "15.5.5", "ical-generator": "^8.0.1", "lucide-react": "^0.503.0", "mime-types": "^2.1.35", "neverthrow": "^8.2.0", - "next": "15.5.1", + "next": "15.5.5", "next-themes": "^0.3.0", "node-ical": "^0.20.1", "nuqs": "^2.4.1", @@ -65,9 +65,9 @@ "postcss": "8.4.23", "posthog-js": "^1.242.2", "posthog-node": "^4.17.1", - "react": "19.1.1", + "react": "19.2.0", "react-day-picker": "^8.10.1", - "react-dom": "19.1.1", + "react-dom": "19.2.0", "react-email": "^4.0.17", "react-markdown": "^10.1.0", "resend": "^4.1.2", @@ -91,8 +91,8 @@ "@tailwindcss/typography": "^0.5.13", "@types/mime-types": "^2.1.4", "@types/node": "20.1.0", - "@types/react": "19.1.11", - "@types/react-dom": "19.1.8", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", "drizzle-kit": "^0.31.4", "encoding": "^0.1.13", "typescript": "5.7.2", @@ -103,8 +103,8 @@ "@sentry/cli", ], "overrides": { - "@types/react": "19.1.11", - "@types/react-dom": "19.1.8", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", }, "packages": { "@ai-sdk/openai": ["@ai-sdk/openai@1.3.22", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-QwA+2EkG0QyjVR+7h6FE7iOu2ivNqAVMm9UJZkVxxTk5OIq5fFJDTEI/zICEMuHImTTXR2JjsL6EirJ28Jc4cw=="], @@ -459,25 +459,25 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.7", "", { "dependencies": { "@emnapi/core": "^1.3.1", "@emnapi/runtime": "^1.3.1", "@tybys/wasm-util": "^0.9.0" } }, "sha512-5yximcFK5FNompXfJFoWanu5l8v1hNGqNHh9du1xETp9HWk/B/PzvchX55WYOPaIeNglG8++68AAiauBAtbnzw=="], - "@next/env": ["@next/env@15.5.1", "", {}, "sha512-b0k/N8gq6c3/hD4gjrapQPtzKwWzNv4fZmLUVGZmq3vZ8nwBFRAlnBWSa69y2s6oEYr5IQSVIzBukDnpsYnr3A=="], + "@next/env": ["@next/env@15.5.5", "", {}, "sha512-2Zhvss36s/yL+YSxD5ZL5dz5pI6ki1OLxYlh6O77VJ68sBnlUrl5YqhBgCy7FkdMsp9RBeGFwpuDCdpJOqdKeQ=="], - "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.1", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-u8chjIoGrGMs5J/yg/xGX83A33qbVS/al2PEqsgJEhLLKihPXOkPoqj65bZ8KASGpDd/j9/nZ1tsCQFau0hITA=="], + "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.5", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-FMzm412l9oFB8zdRD+K6HQ1VzlS+sNNsdg0MfvTg0i8lfCyTgP/RFxiu/pGJqZ/IQnzn9xSiLkjOVI7Iv4nbdQ=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0sLFebvcOJvqvdu/Cj4mCtoN05H/b6gxRSxK8V+Dl+iH0LNROJb0mw9HrDJb5G/RC7BTj2URc2WytzLzyAeVNg=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lYExGHuFIHeOxf40mRLWoA84iY2sLELB23BV5FIDHhdJkN1LpRTPc1MDOawgTo5ifbM5dvAwnGuHyNm60G1+jw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-r3Pdx8Yo1g0P/jmeJLLxwJG4FV31+4KDv4gXYQhllfTn03MW6+CRxY0wJqCTlRyeHM9CbP+u8NhrRxf/NdDGRg=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-cacs/WQqa96IhqUm+7CY+z/0j9sW6X80KE07v3IAJuv+z0UNvJtKSlT/T1w1SpaQRa9l0wCYYZlRZUhUOvEVmg=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-wOEnPEOEw3c2s6IT7xxpyDcoEus/4qEfwgcSx5FrQGKM4iLbatoY6NVMa0aXk7c0jgdDEB1QHr2uMaW+Uf2WkA=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-tLd90SvkRFik6LSfuYjcJEmwqcNEnVYVOyKTacSazya/SLlSwy/VYKsDE4GIzOBd+h3gW+FXqShc2XBavccHCg=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZSDlq5fa7nJkm4jzLDehKCGLYFwf+7je/rUHcohKU7ixM9r22LBvxuKLj1tDwXdSvvLa0Bid32yaMLNpjO4tQ=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ekV76G2R/l3nkvylkfy9jBSYHeB4QcJ7LdDseT6INnn1p51bmDS1eGoSoq+RxfQ7B1wt+Qa0pIl5aqcx0GLpbw=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-2dgh88CXYp1KtGRmKIGxlXuv99Vr5ZyFLLp82JN0A1ktox0TJ/hJCBpVK4VBrhNxCZenKehbm3jIxRxNYkQPAg=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.5", "", { "os": "linux", "cpu": "x64" }, "sha512-tI+sBu+3FmWtqlqD4xKJcj3KJtqbniLombKTE7/UWyyoHmOyAo3aZ7QcEHIOgInXOG1nt0rwh0KGmNbvSB0Djg=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+qmNVv1/af0yl/HaZJqzjXJn0Y+p+liF3MVT8cTjzxUjXKlqgJVBFJIhue2mynUFqylOC9Y3LlaS4Cfu0osFHw=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.5", "", { "os": "linux", "cpu": "x64" }, "sha512-kDRh+epN/ulroNJLr+toDjN+/JClY5L+OAWjOrrKCI0qcKvTw9GBx7CU/rdA2bgi4WpZN3l0rf/3+b8rduEwrQ=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-WfHWqbyxZQls3xGowCChTTZS9V3Bffvtm9A23aNFO6WUSY4vda5vaUBm+b13PunUKfSJC/61J93ISMG0KQnOtw=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GDgdNPFFqiKjTrmfw01sMMRWhVN5wOCmFzPloxa7ksDfX6TZt62tAK986f0ZYqWpvDFqeBCLAzmgTURvtQBdgw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5bA9i7J9lxkor9/IcmbPIcfXCJVX0pa6I9C1kt9S5xxLx6GzQTGdZPd/jKTgtyBGLiMxnDqF38IIZiGs7jDGKg=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.5", "", { "os": "win32", "cpu": "x64" }, "sha512-5kE3oRJxc7M8RmcTANP8RGoJkaYlwIiDD92gSwCjJY0+j8w8Sl1lvxgQ3bxfHY2KkHFai9tpy/Qx1saWV8eaJQ=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -1015,9 +1015,9 @@ "@types/pg-pool": ["@types/pg-pool@2.0.6", "", { "dependencies": { "@types/pg": "*" } }, "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ=="], - "@types/react": ["@types/react@19.1.11", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ=="], + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], - "@types/react-dom": ["@types/react-dom@19.1.8", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ=="], + "@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="], "@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="], @@ -1419,7 +1419,7 @@ "eslint": ["eslint@9.22.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.19.2", "@eslint/config-helpers": "^0.1.0", "@eslint/core": "^0.12.0", "@eslint/eslintrc": "^3.3.0", "@eslint/js": "9.22.0", "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ=="], - "eslint-config-next": ["eslint-config-next@15.5.1", "", { "dependencies": { "@next/eslint-plugin-next": "15.5.1", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-sqYppdNWT7KehpKD0lt+2rm7SgnXc821QFTURFOyeh1aO/TYA3zadh0q84XfW0b8JNIkeay85Bx1hR/vA+6kfw=="], + "eslint-config-next": ["eslint-config-next@15.5.5", "", { "dependencies": { "@next/eslint-plugin-next": "15.5.5", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-f8lRSSelp6cqrYjxEMjJ5En3WV913gTu/w9goYShnIujwDSQlKt4x9MwSDiduE9R5mmFETK44+qlQDxeSA0rUA=="], "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], @@ -1935,7 +1935,7 @@ "neverthrow": ["neverthrow@8.2.0", "", { "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.24.0" } }, "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ=="], - "next": ["next@15.5.1", "", { "dependencies": { "@next/env": "15.5.1", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.1", "@next/swc-darwin-x64": "15.5.1", "@next/swc-linux-arm64-gnu": "15.5.1", "@next/swc-linux-arm64-musl": "15.5.1", "@next/swc-linux-x64-gnu": "15.5.1", "@next/swc-linux-x64-musl": "15.5.1", "@next/swc-win32-arm64-msvc": "15.5.1", "@next/swc-win32-x64-msvc": "15.5.1", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-/SOAQnaT8JGBiWy798xSKhBBR6kcqbbri3uNTwwru8vyCZptU14AiZXYYTExvXGbQCl97jRWNlKbOr5t599Vxw=="], + "next": ["next@15.5.5", "", { "dependencies": { "@next/env": "15.5.5", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.5", "@next/swc-darwin-x64": "15.5.5", "@next/swc-linux-arm64-gnu": "15.5.5", "@next/swc-linux-arm64-musl": "15.5.5", "@next/swc-linux-x64-gnu": "15.5.5", "@next/swc-linux-x64-musl": "15.5.5", "@next/swc-win32-arm64-msvc": "15.5.5", "@next/swc-win32-x64-msvc": "15.5.5", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-OQVdBPtpBfq7HxFN0kOVb7rXXOSIkt5lTzDJDGRBcOyVvNRIWFauMqi1gIHd1pszq1542vMOGY0HP4CaiALfkA=="], "next-themes": ["next-themes@0.3.0", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18", "react-dom": "^16.8 || ^17 || ^18" } }, "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w=="], @@ -2143,11 +2143,11 @@ "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], - "react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], "react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="], - "react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="], + "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], "react-email": ["react-email@4.0.17", "", { "dependencies": { "@babel/parser": "^7.27.0", "@babel/traverse": "^7.27.0", "chalk": "^5.0.0", "chokidar": "^4.0.3", "commander": "^13.0.0", "debounce": "^2.0.0", "esbuild": "^0.25.0", "glob": "^11.0.0", "log-symbols": "^7.0.0", "mime-types": "^3.0.0", "next": "^15.3.1", "normalize-path": "^3.0.0", "ora": "^8.0.0", "socket.io": "^4.8.1", "tsconfig-paths": "4.2.0" }, "bin": { "email": "dist/cli/index.mjs" } }, "sha512-Wppdxgio/QKNe3piccIhk6jvgiLwfOTAwud5t/NlWL8wepsirgXo09OyZCz62Qb9flFNBw+Hz5ahOkpk3JaizQ=="], @@ -2241,7 +2241,7 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "schema-utils": ["schema-utils@4.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g=="], diff --git a/components/core/client-redirect.tsx b/components/core/client-redirect.tsx new file mode 100644 index 00000000..f2cdd3b0 --- /dev/null +++ b/components/core/client-redirect.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useEffect } from "react"; +import Loading from "@/app/loading"; + +export const ClientRedirect = ({ path }: { path: string }) => { + useEffect(() => { + console.log("Client redirect to", path); + window.location.href = path; + }, [path]); + + return ; +}; diff --git a/components/layout/navbar.tsx b/components/layout/navbar.tsx index 23ed00e9..dea945b9 100644 --- a/components/layout/navbar.tsx +++ b/components/layout/navbar.tsx @@ -2,13 +2,13 @@ import { OrganizationSwitcher, UserButton } from "@clerk/nextjs"; import { dark } from "@clerk/themes"; -import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { useQueries } from "@tanstack/react-query"; import { ChevronDown, HelpCircle } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTheme } from "next-themes"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -46,14 +46,6 @@ export function Navbar({ notificationsWire }: { notificationsWire: string }) { const trpc = useTRPC(); - const queryClient = useQueryClient(); - - useEffect(() => { - queryClient.invalidateQueries({ - refetchType: "all", - }); - }, [queryClient]); - const [{ data: projects = [] }, { data: tasklists = [] }] = useQueries({ queries: [ trpc.user.getProjects.queryOptions({ diff --git a/lib/utils/useDatabase.ts b/lib/utils/useDatabase.ts index ec42f379..33457d2e 100644 --- a/lib/utils/useDatabase.ts +++ b/lib/utils/useDatabase.ts @@ -1,40 +1,18 @@ -import { currentUser } from "@clerk/nextjs/server"; import { sql } from "drizzle-orm"; import { upstashCache } from "drizzle-orm/cache/upstash"; import { drizzle } from "drizzle-orm/node-postgres"; import { err, ok, type Result } from "neverthrow"; import type { Database } from "@/drizzle/types"; -import { addUserToOpsDb } from "@/ops/useOps"; import * as schema from "../../drizzle/schema"; import { getOwner } from "./useOwner"; -import { addUserToTenantDb } from "./useUser"; -const connectionPool = new Map(); -const connectionTimestamps = new Map(); - -function getDatabaseName(ownerId: string): Result { +export function getDatabaseName(ownerId: string): Result { if (!ownerId.startsWith("org_") && !ownerId.startsWith("user_")) { return err("Invalid owner ID"); } return ok(ownerId.toLowerCase().replace(/ /g, "_")); } -export async function isDatabaseReady(): Promise { - try { - const userData = await currentUser(); - if (!userData) { - throw new Error("No user found"); - } - - await Promise.all([addUserToTenantDb(userData), addUserToOpsDb(userData)]); - - return true; - } catch (error) { - console.error("Database setup failed:", error); - return false; - } -} - export async function database(): Promise { const { ownerId } = await getOwner(); if (!ownerId) { @@ -45,16 +23,8 @@ export async function database(): Promise { } export async function getDatabaseForOwner(ownerId: string): Promise { - const cachedConnection = connectionPool.get(ownerId); - if (cachedConnection) { - connectionTimestamps.set(ownerId, Date.now()); - return cachedConnection; - } - const databaseName = getDatabaseName(ownerId).match( - (value) => { - return value; - }, + (value) => value, () => { throw new Error("Database name not found"); }, @@ -62,17 +32,6 @@ export async function getDatabaseForOwner(ownerId: string): Promise { 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)}`); - } - const tenantDb = drizzle( `${process.env.DATABASE_URL}/${databaseName}${sslMode}`, { @@ -85,25 +44,17 @@ export async function getDatabaseForOwner(ownerId: string): Promise { }, ); - connectionPool.set(ownerId, tenantDb); - connectionTimestamps.set(ownerId, Date.now()); - return tenantDb; } export async function deleteDatabase(ownerId: string) { const databaseName = getDatabaseName(ownerId).match( - (value) => { - return value; - }, + (value) => value, () => { throw new Error("Database name not found"); }, ); - connectionPool.delete(ownerId); - connectionTimestamps.delete(ownerId); - const sslMode = process.env.DATABASE_SSL === "true" ? "?sslmode=require" : ""; const ownerDb = drizzle(`${process.env.DATABASE_URL}/manage${sslMode}`, { diff --git a/lib/utils/useUser.ts b/lib/utils/useUser.ts index 6b48dec0..96f14436 100644 --- a/lib/utils/useUser.ts +++ b/lib/utils/useUser.ts @@ -1,17 +1,15 @@ -import type { currentUser } from "@clerk/nextjs/server"; +import type { UserJSON } from "@clerk/nextjs/server"; import { user } from "@/drizzle/schema"; import type { User } from "@/drizzle/types"; import { database } from "./useDatabase"; import { getOwner } from "./useOwner"; -export async function addUserToTenantDb( - userData?: Awaited>, -) { +export async function addUserToTenantDb(userData: UserJSON) { if (!userData) { throw new Error("No user found"); } - if (!userData.emailAddresses || userData.emailAddresses.length === 0) { + if (!userData.email_addresses || userData.email_addresses.length === 0) { throw new Error("The user has no associated email addresses."); } @@ -21,20 +19,20 @@ export async function addUserToTenantDb( .insert(user) .values({ id: userData.id, - email: userData.emailAddresses?.[0].emailAddress, - firstName: userData.firstName, - lastName: userData.lastName, - imageUrl: userData.imageUrl, + email: userData.email_addresses?.[0].email_address, + firstName: userData.first_name, + lastName: userData.last_name, + imageUrl: userData.image_url, rawData: userData, lastActiveAt: new Date(), }) .onConflictDoUpdate({ target: user.id, set: { - email: userData.emailAddresses?.[0].emailAddress, - firstName: userData.firstName, - lastName: userData.lastName, - imageUrl: userData.imageUrl, + email: userData.email_addresses?.[0].email_address, + firstName: userData.first_name, + lastName: userData.last_name, + imageUrl: userData.image_url, rawData: userData, lastActiveAt: new Date(), }, diff --git a/ops/useOps.ts b/ops/useOps.ts index 723d060f..b6ecf0a9 100644 --- a/ops/useOps.ts +++ b/ops/useOps.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { auth, clerkClient, type currentUser } from "@clerk/nextjs/server"; +import type { UserJSON } from "@clerk/nextjs/server"; import { drizzle } from "drizzle-orm/node-postgres"; import { migrate } from "drizzle-orm/node-postgres/migrator"; import * as schema from "./drizzle/schema"; @@ -18,15 +18,11 @@ export async function getOpsDatabase(): Promise { return ownerDb; } -export async function addUserToOpsDb( - userData?: Awaited>, -) { - const { orgId } = await auth(); - +export async function addUserToOpsDb(userData: UserJSON) { if (!userData) { throw new Error("No user found"); } - if (!userData.emailAddresses || userData.emailAddresses.length === 0) { + if (!userData.email_addresses || userData.email_addresses.length === 0) { throw new Error("The user has no associated email addresses."); } @@ -36,49 +32,23 @@ export async function addUserToOpsDb( .insert(schema.opsUser) .values({ id: userData.id, - email: userData.emailAddresses?.[0].emailAddress, - firstName: userData.firstName, - lastName: userData.lastName, - imageUrl: userData.imageUrl, + email: userData.email_addresses?.[0].email_address, + firstName: userData.first_name, + lastName: userData.last_name, + imageUrl: userData.image_url, rawData: userData, lastActiveAt: new Date(), }) .onConflictDoUpdate({ target: schema.opsUser.id, set: { - email: userData.emailAddresses?.[0].emailAddress, - firstName: userData.firstName, - lastName: userData.lastName, - imageUrl: userData.imageUrl, + email: userData.email_addresses?.[0].email_address, + firstName: userData.first_name, + lastName: userData.last_name, + imageUrl: userData.image_url, rawData: userData, lastActiveAt: new Date(), }, }) .execute(); - - if (orgId) { - const clerk = await clerkClient(); - const organization = await clerk.organizations.getOrganization({ - organizationId: orgId, - }); - db.insert(schema.opsOrganization) - .values({ - id: organization.id, - name: organization.name, - rawData: organization, - lastActiveAt: new Date(), - }) - .onConflictDoUpdate({ - target: schema.opsOrganization.id, - set: { - name: organization.name, - rawData: organization, - lastActiveAt: new Date(), - markedForDeletionAt: null, - finalWarningAt: null, - }, - }) - .execute(); - console.log("org added to ops database"); - } } diff --git a/package.json b/package.json index 97765ba5..63213889 100644 --- a/package.json +++ b/package.json @@ -62,12 +62,12 @@ "dotenv": "^16.4.7", "drizzle-orm": "^0.44.5", "es-toolkit": "^1.39.8", - "eslint-config-next": "15.5.1", + "eslint-config-next": "15.5.5", "ical-generator": "^8.0.1", "lucide-react": "^0.503.0", "mime-types": "^2.1.35", "neverthrow": "^8.2.0", - "next": "15.5.1", + "next": "15.5.5", "next-themes": "^0.3.0", "node-ical": "^0.20.1", "nuqs": "^2.4.1", @@ -75,9 +75,9 @@ "postcss": "8.4.23", "posthog-js": "^1.242.2", "posthog-node": "^4.17.1", - "react": "19.1.1", + "react": "19.2.0", "react-day-picker": "^8.10.1", - "react-dom": "19.1.1", + "react-dom": "19.2.0", "react-email": "^4.0.17", "react-markdown": "^10.1.0", "resend": "^4.1.2", @@ -101,15 +101,15 @@ "@tailwindcss/typography": "^0.5.13", "@types/mime-types": "^2.1.4", "@types/node": "20.1.0", - "@types/react": "19.1.11", - "@types/react-dom": "19.1.8", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", "drizzle-kit": "^0.31.4", "encoding": "^0.1.13", "typescript": "5.7.2" }, "overrides": { - "@types/react": "19.1.11", - "@types/react-dom": "19.1.8" + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2" }, "trustedDependencies": [ "@sentry/cli" diff --git a/trpc/query-client.ts b/trpc/query-client.ts index a9fcd3fd..e9559141 100644 --- a/trpc/query-client.ts +++ b/trpc/query-client.ts @@ -1,9 +1,9 @@ -import { toMs } from "@/lib/utils/date"; import { - QueryClient, defaultShouldDehydrateQuery, + QueryClient, } from "@tanstack/react-query"; import superjson from "superjson"; +import { toMs } from "@/lib/utils/date"; export function makeQueryClient() { return new QueryClient({ From 0dcac5e9dd8c4887097f9cc77a4c34ba74165dd8 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 15 Oct 2025 18:15:52 +1100 Subject: [PATCH 5/8] Fix signup user creation --- lib/utils/useUser.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/utils/useUser.ts b/lib/utils/useUser.ts index 96f14436..69f043f8 100644 --- a/lib/utils/useUser.ts +++ b/lib/utils/useUser.ts @@ -1,7 +1,7 @@ import type { UserJSON } from "@clerk/nextjs/server"; import { user } from "@/drizzle/schema"; import type { User } from "@/drizzle/types"; -import { database } from "./useDatabase"; +import { database, getDatabaseForOwner } from "./useDatabase"; import { getOwner } from "./useOwner"; export async function addUserToTenantDb(userData: UserJSON) { @@ -13,7 +13,7 @@ export async function addUserToTenantDb(userData: UserJSON) { throw new Error("The user has no associated email addresses."); } - const db = await database(); + const db = await getDatabaseForOwner(userData.id); await db .insert(user) From f09cbbe15a702e10a408b0f79efe7d63d2d058fd Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 15 Oct 2025 18:45:57 +1100 Subject: [PATCH 6/8] Handle org user changes --- app/(api)/api/webhook/auth/route.ts | 106 +++++++++++++++++++++++++++- hooks/use-keyboard-shortcut.ts | 1 - hooks/use-project-prefetch.ts | 1 - lib/utils/todayData.ts | 5 -- scripts/migrate-all-tenants.ts | 13 +++- trpc/routers/events.ts | 9 ++- trpc/routers/settings.ts | 10 +-- trpc/routers/user.ts | 69 ++++++++---------- 8 files changed, 154 insertions(+), 60 deletions(-) diff --git a/app/(api)/api/webhook/auth/route.ts b/app/(api)/api/webhook/auth/route.ts index bc63bdc5..5efd0c4f 100644 --- a/app/(api)/api/webhook/auth/route.ts +++ b/app/(api)/api/webhook/auth/route.ts @@ -11,7 +11,11 @@ import { } from "@/components/emails/account-deleted"; import * as schema from "@/drizzle/schema"; import { SearchService } from "@/lib/search"; -import { deleteDatabase, getDatabaseName } 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"; @@ -30,6 +34,7 @@ enum WebhookEventType { organizationCreated = "organization.created", organizationDeleted = "organization.deleted", organizationUpdated = "organization.updated", + organizationInvitationAccepted = "organizationInvitation.accepted", userCreated = "user.created", userDeleted = "user.deleted", userUpdated = "user.updated", @@ -121,6 +126,53 @@ export async function POST(req: NextRequest) { 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", ); @@ -156,6 +208,58 @@ export async function POST(req: NextRequest) { 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 diff --git a/hooks/use-keyboard-shortcut.ts b/hooks/use-keyboard-shortcut.ts index 625df427..3f2fc317 100644 --- a/hooks/use-keyboard-shortcut.ts +++ b/hooks/use-keyboard-shortcut.ts @@ -36,4 +36,3 @@ export function useKeyboardShortcut( return () => document.removeEventListener("keydown", handleKeyDown); }, [key, callback, enabled]); } - diff --git a/hooks/use-project-prefetch.ts b/hooks/use-project-prefetch.ts index 4fcc8632..c6551232 100644 --- a/hooks/use-project-prefetch.ts +++ b/hooks/use-project-prefetch.ts @@ -56,4 +56,3 @@ export function useProjectPrefetch(projectId: number) { prefetchData(); }, [projectId, queryClient, trpc]); } - diff --git a/lib/utils/todayData.ts b/lib/utils/todayData.ts index d3dd0d70..d50a0bb2 100644 --- a/lib/utils/todayData.ts +++ b/lib/utils/todayData.ts @@ -22,7 +22,6 @@ export async function getTodayDataForUser( const { startOfDay, endOfDay } = getStartEndDateRangeInUtc(timezone, date); const [tasksDueToday, overDueTasks, events] = await Promise.all([ - // Tasks due today db.query.task.findMany({ where: and( between(task.dueDate, startOfDay, endOfDay), @@ -53,7 +52,6 @@ export async function getTodayDataForUser( }, }, }), - // Overdue tasks db.query.task.findMany({ where: and( lt(task.dueDate, startOfDay), @@ -84,7 +82,6 @@ export async function getTodayDataForUser( }, }, }), - // Today's events db.query.calendarEvent.findMany({ where: and( or( @@ -111,7 +108,6 @@ export async function getTodayDataForUser( }), ]); - // Filter out archived task lists const dueToday = tasksDueToday.filter( (t) => t.taskList?.status !== TaskListStatus.ARCHIVED, ); @@ -120,7 +116,6 @@ export async function getTodayDataForUser( (t) => t.taskList?.status !== TaskListStatus.ARCHIVED, ); - // Filter events by repeat rule const filteredEvents = events.filter((event) => filterByRepeatRule(event, date, timezone), ); diff --git a/scripts/migrate-all-tenants.ts b/scripts/migrate-all-tenants.ts index f16cfc14..4c05a2c7 100644 --- a/scripts/migrate-all-tenants.ts +++ b/scripts/migrate-all-tenants.ts @@ -16,7 +16,9 @@ function maskId(id: string): string { function sanitizeError(error: string, tenantId: string): string { const databaseName = getDatabaseName(tenantId); - return error.replace(new RegExp(tenantId, "g"), maskId(tenantId)).replace(new RegExp(databaseName, "g"), "tenant_db"); + return error + .replace(new RegExp(tenantId, "g"), maskId(tenantId)) + .replace(new RegExp(databaseName, "g"), "tenant_db"); } async function getOpsDatabase() { @@ -122,7 +124,10 @@ async function main() { } } else { console.log("✗"); - const sanitizedError = sanitizeError(result.error || "Unknown error", tenantId); + const sanitizedError = sanitizeError( + result.error || "Unknown error", + tenantId, + ); console.log(` Error: ${sanitizedError}`); failureCount++; failures.push({ tenantId: maskedId, error: sanitizedError }); @@ -150,7 +155,9 @@ async function main() { main().catch((error) => { const errorMessage = error instanceof Error ? error.message : String(error); - const sanitized = errorMessage.replace(/user_[a-zA-Z0-9]+/g, "user_***").replace(/org_[a-zA-Z0-9]+/g, "org_***"); + const sanitized = errorMessage + .replace(/user_[a-zA-Z0-9]+/g, "user_***") + .replace(/org_[a-zA-Z0-9]+/g, "org_***"); console.error("Fatal error:", sanitized); process.exit(1); }); diff --git a/trpc/routers/events.ts b/trpc/routers/events.ts index d1e542d6..7f6602c9 100644 --- a/trpc/routers/events.ts +++ b/trpc/routers/events.ts @@ -26,7 +26,7 @@ import { lt, or, } from "drizzle-orm"; -import { Frequency, RRule, } from "rrule"; +import { Frequency, RRule } from "rrule"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../init"; import { sendMentionNotifications } from "@/lib/utils/mentionNotifications"; @@ -379,13 +379,16 @@ export const eventsRouter = createTRPCRouter({ try { const events = ical.parseICS(icsContent); - + const importedEvents: ImportedEvent[] = []; const errors: string[] = []; for (const [key, event] of Object.entries(events)) { try { - const importedEvent = convertIcsEventToImportedEvent(event, ctx.timezone); + const importedEvent = convertIcsEventToImportedEvent( + event, + ctx.timezone, + ); if (importedEvent) { importedEvents.push(importedEvent); } diff --git a/trpc/routers/settings.ts b/trpc/routers/settings.ts index 020a4573..28cffd97 100644 --- a/trpc/routers/settings.ts +++ b/trpc/routers/settings.ts @@ -1,10 +1,10 @@ +import { eq, sql } from "drizzle-orm"; +import { cookies } from "next/headers"; +import { z } from "zod"; import { blob, user } from "@/drizzle/schema"; import type { User } from "@/drizzle/types"; import { opsUser } from "@/ops/drizzle/schema"; import { getOpsDatabase } from "@/ops/useOps"; -import { eq, sql } from "drizzle-orm"; -import { cookies } from "next/headers"; -import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../init"; export const settingsRouter = createTRPCRouter({ @@ -35,7 +35,7 @@ export const settingsRouter = createTRPCRouter({ // Update main user database await ctx.db .update(user) - .set({ timeZone: input }) + .set({ timeZone: input, lastActiveAt: new Date() }) .where(eq(user.id, ctx.userId)) .execute(); @@ -44,7 +44,7 @@ export const settingsRouter = createTRPCRouter({ const opsDb = await getOpsDatabase(); await opsDb .update(opsUser) - .set({ timeZone: input }) + .set({ timeZone: input, lastActiveAt: new Date() }) .where(eq(opsUser.id, ctx.userId)) .execute(); } catch (error) { diff --git a/trpc/routers/user.ts b/trpc/routers/user.ts index fed798de..e561453c 100644 --- a/trpc/routers/user.ts +++ b/trpc/routers/user.ts @@ -1,3 +1,6 @@ +import { currentUser } from "@clerk/nextjs/server"; +import { desc, eq, or } from "drizzle-orm"; +import { z } from "zod"; import { notification, project, @@ -7,9 +10,6 @@ import { import type { NotificationWithUser } from "@/drizzle/types"; import { getTodayDataForUser } from "@/lib/utils/todayData"; import { broadcastEvent, getSignedWireUrl } from "@/lib/utils/turbowire"; -import { currentUser } from "@clerk/nextjs/server"; -import { and, desc, eq, inArray, or } from "drizzle-orm"; -import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../init"; export const userRouter = createTRPCRouter({ @@ -65,56 +65,43 @@ export const userRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { const statuses = input?.statuses ?? ["active"]; - // First, get the project IDs that the user has permission to access - const userPermissions = await ctx.db.query.projectPermission.findMany({ - where: eq(projectPermission.userId, ctx.userId), - columns: { - projectId: true, - role: true, - }, - }); - const statusFilter = statuses?.map((status) => eq(project.status, status), ); - // Get projects where user has explicit permissions OR is the creator const projects = await ctx.db.query.project.findMany({ - where: ctx.isOrgAdmin - ? or(...statusFilter) - : and( - or( - // User has explicit permission - userPermissions.length > 0 - ? inArray( - project.id, - userPermissions.map((p) => p.projectId), - ) - : undefined, - // User is the creator (for backward compatibility) - eq(project.createdByUser, ctx.userId), - ), - or(...statusFilter), - ), + where: or(...statusFilter), with: { creator: true, + permissions: { + where: eq(projectPermission.userId, ctx.userId), + columns: { + role: true, + }, + }, }, }); - // Add user's role to each project - const projectsWithRole = projects.map((proj) => { - const permission = userPermissions.find((p) => p.projectId === proj.id); - // If no explicit permission but user is creator, they have editor role - const role = - permission?.role || - (proj.createdByUser === ctx.userId ? "editor" : undefined); - return { + if (ctx.isOrgAdmin) { + return projects.map((proj) => ({ ...proj, - userRole: role, - }; - }); + userRole: + proj.permissions[0]?.role || + (proj.createdByUser === ctx.userId ? "editor" : "editor"), + })); + } + + const userProjects = projects.filter( + (proj) => + proj.permissions.length > 0 || proj.createdByUser === ctx.userId, + ); - return projectsWithRole; + return userProjects.map((proj) => ({ + ...proj, + userRole: + proj.permissions[0]?.role || + (proj.createdByUser === ctx.userId ? "editor" : undefined), + })); }), searchUsersForMention: protectedProcedure .input( From cc65597b290890664332a74262e371dc317771de Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 15 Oct 2025 18:48:21 +1100 Subject: [PATCH 7/8] Update workflow permissions --- .github/workflows/migrate-tenants.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/migrate-tenants.yml b/.github/workflows/migrate-tenants.yml index 4cc0547c..a4c298fa 100644 --- a/.github/workflows/migrate-tenants.yml +++ b/.github/workflows/migrate-tenants.yml @@ -1,4 +1,6 @@ name: Migrate Tenant Databases +permissions: + contents: read on: push: From cdb0aeb6eba6def2bb95b9c2929aa47237dfab87 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 15 Oct 2025 19:05:51 +1100 Subject: [PATCH 8/8] Address CR --- scripts/migrate-all-tenants.ts | 8 ++++---- trpc/routers/events.ts | 36 +++++++++++++++++----------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/scripts/migrate-all-tenants.ts b/scripts/migrate-all-tenants.ts index 4c05a2c7..c1ce6fe8 100644 --- a/scripts/migrate-all-tenants.ts +++ b/scripts/migrate-all-tenants.ts @@ -1,7 +1,7 @@ import path from "node:path"; +import { sql } from "drizzle-orm"; import { drizzle } from "drizzle-orm/node-postgres"; import { migrate } from "drizzle-orm/node-postgres/migrator"; -import { sql } from "drizzle-orm"; import * as schema from "../drizzle/schema"; import * as opsSchema from "../ops/drizzle/schema"; @@ -17,8 +17,8 @@ function maskId(id: string): string { function sanitizeError(error: string, tenantId: string): string { const databaseName = getDatabaseName(tenantId); return error - .replace(new RegExp(tenantId, "g"), maskId(tenantId)) - .replace(new RegExp(databaseName, "g"), "tenant_db"); + .replaceAll(tenantId, maskId(tenantId)) + .replaceAll(databaseName, "tenant_db"); } async function getOpsDatabase() { @@ -134,7 +134,7 @@ async function main() { } } - console.log("\n" + "=".repeat(60)); + console.log(`\n${"=".repeat(60)}`); console.log("Migration Summary:"); console.log(` Total: ${tenantIds.length}`); console.log(` Success: ${successCount}`); diff --git a/trpc/routers/events.ts b/trpc/routers/events.ts index 7f6602c9..b23f0805 100644 --- a/trpc/routers/events.ts +++ b/trpc/routers/events.ts @@ -1,19 +1,4 @@ import { TRPCError } from "@trpc/server"; -import { calendarEvent } from "@/drizzle/schema"; -import { logActivity } from "@/lib/activity"; -import { canEditProject, canViewProject } from "@/lib/permissions"; -import { - deleteSearchItem, - indexEventWithProjectFetch, -} from "@/lib/search/helpers"; -import { toEndOfDay, toStartOfDay, toTimeZone, toUTC } from "@/lib/utils/date"; -import { - getStartEndDateRangeInUtc, - getStartEndMonthRangeInUtc, - getStartEndWeekRangeInUtc, - convertIcsEventToImportedEvent, - type ImportedEvent, -} from "@/lib/utils/useEvents"; import { isAfter } from "date-fns"; import { and, @@ -26,11 +11,26 @@ import { lt, or, } from "drizzle-orm"; +import * as ical from "node-ical"; import { Frequency, RRule } from "rrule"; import { z } from "zod"; -import { createTRPCRouter, protectedProcedure } from "../init"; +import { calendarEvent } from "@/drizzle/schema"; +import { logActivity } from "@/lib/activity"; +import { canEditProject, canViewProject } from "@/lib/permissions"; +import { + deleteSearchItem, + indexEventWithProjectFetch, +} from "@/lib/search/helpers"; +import { toEndOfDay, toStartOfDay, toTimeZone, toUTC } from "@/lib/utils/date"; import { sendMentionNotifications } from "@/lib/utils/mentionNotifications"; -import * as ical from "node-ical"; +import { + convertIcsEventToImportedEvent, + getStartEndDateRangeInUtc, + getStartEndMonthRangeInUtc, + getStartEndWeekRangeInUtc, + type ImportedEvent, +} from "@/lib/utils/useEvents"; +import { createTRPCRouter, protectedProcedure } from "../init"; const buildEventsQuery = (projectId: number, start: Date, end: Date) => { return { @@ -260,7 +260,7 @@ export const eventsRouter = createTRPCRouter({ } let repeatRule: string | null = null; - if (repeat) { + if (repeat !== null && repeat !== undefined) { repeatRule = new RRule({ freq: repeat, dtstart: start,