diff --git a/.github/workflows/migrate-tenants.yml b/.github/workflows/migrate-tenants.yml new file mode 100644 index 00000000..a4c298fa --- /dev/null +++ b/.github/workflows/migrate-tenants.yml @@ -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 + diff --git a/.nvmrc b/.nvmrc index 9a2a0e21..53d1c14d 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20 +v22 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/app/(api)/api/webhook/auth/route.ts b/app/(api)/api/webhook/auth/route.ts index 283df25c..5efd0c4f 100644 --- a/app/(api)/api/webhook/auth/route.ts +++ b/app/(api)/api/webhook/auth/route.ts @@ -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?: { @@ -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 { + 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 +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 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/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/lib/utils/useDatabase.ts b/lib/utils/useDatabase.ts index de048f1f..33457d2e 100644 --- a/lib/utils/useDatabase.ts +++ b/lib/utils/useDatabase.ts @@ -1,79 +1,18 @@ -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"; import { getOwner } from "./useOwner"; -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 { +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 migrationResult = await migrateDatabase(); - - if (!migrationResult) { - return false; - } - - 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; - } -} - -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) { @@ -84,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"); }, @@ -101,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}`, { @@ -124,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..69f043f8 100644 --- a/lib/utils/useUser.ts +++ b/lib/utils/useUser.ts @@ -1,40 +1,38 @@ -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 { database, getDatabaseForOwner } 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."); } - const db = await database(); + const db = await getDatabaseForOwner(userData.id); await db .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 53e8e480..63213889 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", @@ -61,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", @@ -74,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", @@ -100,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/scripts/migrate-all-tenants.ts b/scripts/migrate-all-tenants.ts new file mode 100644 index 00000000..c1ce6fe8 --- /dev/null +++ b/scripts/migrate-all-tenants.ts @@ -0,0 +1,163 @@ +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 * as schema from "../drizzle/schema"; +import * as opsSchema from "../ops/drizzle/schema"; + +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 + .replaceAll(tenantId, maskId(tenantId)) + .replaceAll(databaseName, "tenant_db"); +} + +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 maskedId = maskId(tenantId); + const progress = `[${i + 1}/${tenantIds.length}]`; + process.stdout.write(`${progress} Migrating ${maskedId}... `); + + const result = await migrateTenantDatabase(tenantId); + + if (result.success) { + if (result.skipped) { + console.log("⊘ (no database)"); + skippedCount++; + } else { + console.log("✓"); + successCount++; + } + } else { + console.log("✗"); + const sanitizedError = sanitizeError( + result.error || "Unknown error", + tenantId, + ); + console.log(` Error: ${sanitizedError}`); + failureCount++; + failures.push({ tenantId: maskedId, error: sanitizedError }); + } + } + + 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) => { + 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); +}); 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({ diff --git a/trpc/routers/events.ts b/trpc/routers/events.ts index d1e542d6..b23f0805 100644 --- a/trpc/routers/events.ts +++ b/trpc/routers/events.ts @@ -1,4 +1,19 @@ import { TRPCError } from "@trpc/server"; +import { isAfter } from "date-fns"; +import { + and, + asc, + between, + desc, + eq, + gt, + isNotNull, + lt, + or, +} from "drizzle-orm"; +import * as ical from "node-ical"; +import { Frequency, RRule } from "rrule"; +import { z } from "zod"; import { calendarEvent } from "@/drizzle/schema"; import { logActivity } from "@/lib/activity"; import { canEditProject, canViewProject } from "@/lib/permissions"; @@ -7,30 +22,15 @@ import { indexEventWithProjectFetch, } from "@/lib/search/helpers"; import { toEndOfDay, toStartOfDay, toTimeZone, toUTC } from "@/lib/utils/date"; +import { sendMentionNotifications } from "@/lib/utils/mentionNotifications"; import { + convertIcsEventToImportedEvent, getStartEndDateRangeInUtc, getStartEndMonthRangeInUtc, getStartEndWeekRangeInUtc, - convertIcsEventToImportedEvent, type ImportedEvent, } from "@/lib/utils/useEvents"; -import { isAfter } from "date-fns"; -import { - and, - asc, - between, - desc, - eq, - gt, - isNotNull, - lt, - or, -} from "drizzle-orm"; -import { Frequency, RRule, } from "rrule"; -import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../init"; -import { sendMentionNotifications } from "@/lib/utils/mentionNotifications"; -import * as ical from "node-ical"; 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, @@ -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(