From 45927897128a65c9601689a05c547cafb4161999 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Wed, 6 May 2026 20:19:50 +0200 Subject: [PATCH] feat(seed): synthetic fixture covering every pipeline stage + db:reset Splits seed bootstrap (ports/roles/profile) into a shared module so two seed entry points can share it: - pnpm db:seed realistic NocoDB-shaped fixture (existing) - pnpm db:seed:synthetic 12 clients, one per pipeline stage + archive variants (rich metadata for restore wizard) scripts/db-reset.ts truncates all data tables (preserves migrations); guarded by --confirm and a localhost host check. Companion npm scripts: - pnpm db:reset - pnpm db:reseed:realistic - pnpm db:reseed:synthetic scripts/dev-open-browser.ts launches a headed Chromium with no viewport override (uses the host monitor's natural size), pre-fills the login form for the requested role. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 5 + scripts/db-reset.ts | 97 ++++ scripts/dev-open-browser.ts | 80 ++++ src/lib/db/seed-bootstrap.ts | 167 +++++++ src/lib/db/seed-permissions.ts | 477 +++++++++++++++++++ src/lib/db/seed-synthetic-data.ts | 764 ++++++++++++++++++++++++++++++ src/lib/db/seed-synthetic.ts | 55 +++ src/lib/db/seed.ts | 655 +------------------------ 8 files changed, 1656 insertions(+), 644 deletions(-) create mode 100644 scripts/db-reset.ts create mode 100644 scripts/dev-open-browser.ts create mode 100644 src/lib/db/seed-bootstrap.ts create mode 100644 src/lib/db/seed-permissions.ts create mode 100644 src/lib/db/seed-synthetic-data.ts create mode 100644 src/lib/db/seed-synthetic.ts diff --git a/package.json b/package.json index 689e529..38dd9a7 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,11 @@ "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "db:seed": "tsx src/lib/db/seed.ts", + "db:seed:realistic": "tsx src/lib/db/seed.ts", + "db:seed:synthetic": "tsx src/lib/db/seed-synthetic.ts", + "db:reset": "tsx scripts/db-reset.ts --confirm", + "db:reseed:realistic": "pnpm db:reset && pnpm db:seed:realistic", + "db:reseed:synthetic": "pnpm db:reset && pnpm db:seed:synthetic", "test:e2e": "playwright test", "test:e2e:smoke": "playwright test --project=smoke", "test:e2e:exhaustive": "playwright test --project=exhaustive", diff --git a/scripts/db-reset.ts b/scripts/db-reset.ts new file mode 100644 index 0000000..0784d21 --- /dev/null +++ b/scripts/db-reset.ts @@ -0,0 +1,97 @@ +/** + * Wipe all data from the database, preserving schema + drizzle migration + * history. Run before swapping seed fixtures. + * + * pnpm tsx scripts/db-reset.ts (refuses without --confirm) + * pnpm tsx scripts/db-reset.ts --confirm + * + * Truncates every table in the `public` schema except the drizzle + * migration tracker, then resets sequences. Wraps the loop in a single + * transaction so a mid-wipe failure rolls back cleanly. + * + * Refuses to run when DATABASE_URL points at anything that doesn't look + * like a local/dev host. Override with --i-know-what-im-doing. + */ + +import 'dotenv/config'; +import postgres from 'postgres'; + +const url = process.env.DATABASE_URL; +if (!url) { + console.error('DATABASE_URL is not set; aborting.'); + process.exit(1); +} + +const args = new Set(process.argv.slice(2)); +if (!args.has('--confirm')) { + console.error('Refusing to wipe without --confirm'); + console.error('Run again as: pnpm tsx scripts/db-reset.ts --confirm'); + process.exit(1); +} + +// Best-effort safety: refuse for anything that doesn't look like a local DB. +function looksLocal(u: string): boolean { + try { + const parsed = new URL(u); + return ( + parsed.hostname === 'localhost' || + parsed.hostname === '127.0.0.1' || + parsed.hostname === '::1' || + parsed.hostname.endsWith('.local') || + parsed.hostname.endsWith('.internal') || + parsed.hostname === 'host.docker.internal' || + // Docker compose service names commonly used here + parsed.hostname === 'postgres' || + parsed.hostname === 'db' + ); + } catch { + return false; + } +} + +if (!looksLocal(url) && !args.has('--i-know-what-im-doing')) { + console.error( + `DATABASE_URL host doesn't look local. Refusing to wipe a remote DB without --i-know-what-im-doing.`, + ); + process.exit(1); +} + +const sql = postgres(url, { max: 1 }); + +async function main() { + console.log('Resetting database...'); + console.log(` url: ${url.replace(/:[^:@]*@/, ':***@')}`); + + const tables = await sql<{ tablename: string }[]>` + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + AND tablename NOT LIKE 'drizzle_%' + AND tablename != '__drizzle_migrations' + `; + + if (tables.length === 0) { + console.log(' no user tables found, nothing to do.'); + await sql.end(); + return; + } + + // Single TRUNCATE … CASCADE is faster than per-table loops and handles + // FK ordering for us. Quote table names defensively. + const tableList = tables.map((t) => `"public"."${t.tablename}"`).join(', '); + + console.log(` truncating ${tables.length} tables...`); + await sql.unsafe(`TRUNCATE ${tableList} RESTART IDENTITY CASCADE`); + console.log(' done.'); + + await sql.end(); + console.log(''); + console.log('Database reset complete. Run a seed script next:'); + console.log(' pnpm db:seed # realistic NocoDB-shaped fixture'); + console.log(' pnpm db:seed:synthetic # one client per pipeline stage'); +} + +main().catch(async (err) => { + console.error('Reset failed:', err); + await sql.end().catch(() => undefined); + process.exit(1); +}); diff --git a/scripts/dev-open-browser.ts b/scripts/dev-open-browser.ts new file mode 100644 index 0000000..877258c --- /dev/null +++ b/scripts/dev-open-browser.ts @@ -0,0 +1,80 @@ +/** + * Launch a headed Chromium with NO viewport override so it adopts the + * host monitor's natural size — useful when you want to drive the CRM + * manually and have full-screen real estate. + * + * Pre-fills the login form for the synthetic admin (admin@portnimara.test + * / SuperAdmin12345!) but does not submit; press Enter when ready. + * + * The script keeps running until the browser window is closed by the + * user or until you Ctrl-C. + * + * pnpm tsx scripts/dev-open-browser.ts # super_admin + * pnpm tsx scripts/dev-open-browser.ts sales_agent + * pnpm tsx scripts/dev-open-browser.ts viewer + * pnpm tsx scripts/dev-open-browser.ts --no-prefill + */ + +import 'dotenv/config'; +import { chromium } from 'playwright'; + +const USERS: Record = { + super_admin: { email: 'admin@portnimara.test', password: 'SuperAdmin12345!' }, + sales_agent: { email: 'agent@portnimara.test', password: 'SalesAgent12345!' }, + viewer: { email: 'viewer@portnimara.test', password: 'ViewerUser12345!' }, +}; + +const BASE_URL = process.env.DEV_BASE_URL ?? 'http://localhost:3000'; + +async function main() { + const args = process.argv.slice(2); + const noPrefill = args.includes('--no-prefill'); + const role = + args.find((a) => !a.startsWith('--')) && USERS[args.find((a) => !a.startsWith('--'))!] + ? args.find((a) => !a.startsWith('--'))! + : 'super_admin'; + const user = USERS[role]!; + + console.log(`Launching headed Chromium → ${BASE_URL}`); + console.log(` role: ${role} (${user.email})`); + + const browser = await chromium.launch({ + headless: false, + args: ['--start-maximized'], + }); + + // viewport: null lets the page fill the OS window. Combined with + // --start-maximized this matches the host monitor's natural size. + const context = await browser.newContext({ viewport: null }); + const page = await context.newPage(); + + await page.goto(`${BASE_URL}/login`); + + if (!noPrefill) { + try { + await page.waitForSelector('#email', { timeout: 5000 }); + await page.fill('#email', user.email); + await page.fill('#password', user.password); + console.log(' Login form pre-filled — press Enter in the browser to submit.'); + } catch { + console.log(' Could not find login form (page may have redirected).'); + } + } + + console.log(''); + console.log("Browser is open. Close it when you're done; the script will exit."); + console.log('Or Ctrl-C here to force-quit.'); + + // Keep the process alive until the browser window is closed. + await new Promise((resolve) => { + browser.on('disconnected', () => resolve()); + }); + + await browser.close().catch(() => undefined); + process.exit(0); +} + +main().catch((err) => { + console.error('Open-browser failed:', err); + process.exit(1); +}); diff --git a/src/lib/db/seed-bootstrap.ts b/src/lib/db/seed-bootstrap.ts new file mode 100644 index 0000000..d0bd7d4 --- /dev/null +++ b/src/lib/db/seed-bootstrap.ts @@ -0,0 +1,167 @@ +/** + * Shared seed bootstrap: ports + system roles + super admin profile. + * + * Both the realistic seed (`seed.ts`) and the synthetic seed + * (`seed-synthetic.ts`) call into this so we don't drift on the + * permission maps or the operator account ids. + * + * Idempotent. Returns the resolved port ids so callers can chain + * per-port fixture builders. + */ + +import { eq } from 'drizzle-orm'; +import { db } from './index'; +import { ports } from './schema/ports'; +import { roles, userProfiles } from './schema/users'; +import { + ALL_PERMISSIONS, + DIRECTOR_PERMISSIONS, + SALES_MANAGER_PERMISSIONS, + SALES_AGENT_PERMISSIONS, + VIEWER_PERMISSIONS, + RESIDENTIAL_PARTNER_PERMISSIONS, +} from './seed-permissions'; + +export interface BootstrappedPort { + id: string; + name: string; + slug: string; +} + +export const PORT_DEFINITIONS: Array<{ + name: string; + slug: string; + primaryColor: string; + defaultCurrency: string; + timezone: string; +}> = [ + { + name: 'Port Nimara', + slug: 'port-nimara', + primaryColor: '#0F4C81', + defaultCurrency: 'USD', + timezone: 'America/Anguilla', + }, + { + name: 'Port Amador', + slug: 'port-amador', + primaryColor: '#D97706', + defaultCurrency: 'USD', + timezone: 'America/Panama', + }, +]; + +export const SUPER_ADMIN_USER_ID = 'super-admin-matt-portnimara'; + +export async function seedBootstrap(): Promise { + console.log('Bootstrap: ports + roles + super admin profile'); + + // ── Ports ────────────────────────────────────────────────────────────────── + const portIds: BootstrappedPort[] = []; + for (const def of PORT_DEFINITIONS) { + const [inserted] = await db + .insert(ports) + .values({ + id: crypto.randomUUID(), + name: def.name, + slug: def.slug, + logoUrl: null, + primaryColor: def.primaryColor, + defaultCurrency: def.defaultCurrency, + timezone: def.timezone, + settings: {}, + isActive: true, + }) + .onConflictDoNothing() + .returning(); + + if (inserted) { + console.log(` Port created: ${def.name} (${inserted.id})`); + portIds.push({ id: inserted.id, name: def.name, slug: def.slug }); + } else { + const [existing] = await db.select().from(ports).where(eq(ports.slug, def.slug)).limit(1); + if (existing) { + console.log(` Port exists: ${def.name} (${existing.id})`); + portIds.push({ id: existing.id, name: def.name, slug: def.slug }); + } + } + } + + // ── System roles ────────────────────────────────────────────────────────── + const systemRoles = [ + { + id: crypto.randomUUID(), + name: 'super_admin', + description: 'Full system access. Bypasses all permission checks.', + permissions: ALL_PERMISSIONS, + isGlobal: true, + isSystem: true, + }, + { + id: crypto.randomUUID(), + name: 'director', + description: 'Operational admin within assigned port(s). Can manage users and settings.', + permissions: DIRECTOR_PERMISSIONS, + isGlobal: true, + isSystem: true, + }, + { + id: crypto.randomUUID(), + name: 'sales_manager', + description: 'Full sales access. Can view all reminders, assign tasks, and export reports.', + permissions: SALES_MANAGER_PERMISSIONS, + isGlobal: true, + isSystem: true, + }, + { + id: crypto.randomUUID(), + name: 'sales_agent', + description: + 'Standard sales role. View/create/edit clients and interests, manage own reminders.', + permissions: SALES_AGENT_PERMISSIONS, + isGlobal: true, + isSystem: true, + }, + { + id: crypto.randomUUID(), + name: 'viewer', + description: 'Read-only access to all records.', + permissions: VIEWER_PERMISSIONS, + isGlobal: true, + isSystem: true, + }, + { + id: crypto.randomUUID(), + name: 'residential_partner', + description: + 'External partner who handles residential inquiries. Sees only the residential pages — no marina clients, yachts, berths, or financial data.', + permissions: RESIDENTIAL_PARTNER_PERMISSIONS, + isGlobal: true, + isSystem: true, + }, + ]; + + for (const role of systemRoles) { + await db.insert(roles).values(role).onConflictDoNothing(); + } + console.log(` Roles ensured: ${systemRoles.map((r) => r.name).join(', ')}`); + + // ── Super admin profile placeholder ─────────────────────────────────────── + await db + .insert(userProfiles) + .values({ + id: crypto.randomUUID(), + userId: SUPER_ADMIN_USER_ID, + displayName: 'Matt', + avatarUrl: null, + phone: null, + isSuperAdmin: true, + isActive: true, + lastLoginAt: null, + preferences: {}, + }) + .onConflictDoNothing(); + console.log(` Super admin profile ensured (user_id: ${SUPER_ADMIN_USER_ID})`); + + return portIds; +} diff --git a/src/lib/db/seed-permissions.ts b/src/lib/db/seed-permissions.ts new file mode 100644 index 0000000..8c41fe4 --- /dev/null +++ b/src/lib/db/seed-permissions.ts @@ -0,0 +1,477 @@ +/** + * Seed-time permission maps for the six system roles. + * + * Kept in their own module so both `seed.ts` (realistic) and + * `seed-synthetic.ts` can share them without drift, and so the + * giant role/permission grids don't pollute the seed orchestrator. + * + * Keep in sync with `src/lib/db/schema/users.ts → RolePermissions` + * and `src/components/admin/roles/role-form.tsx → DEFAULT_PERMISSIONS`. + */ + +import type { RolePermissions } from './schema/users'; + +export const ALL_PERMISSIONS: RolePermissions = { + clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, + interests: { + view: true, + create: true, + edit: true, + delete: true, + change_stage: true, + override_stage: true, + generate_eoi: true, + export: true, + }, + berths: { view: true, edit: true, import: true, manage_waiting_list: true }, + documents: { + view: true, + create: true, + edit: true, + send_for_signing: true, + upload_signed: true, + delete: true, + }, + expenses: { + view: true, + create: true, + edit: true, + delete: true, + export: true, + scan_receipt: true, + }, + invoices: { + view: true, + create: true, + edit: true, + delete: true, + send: true, + record_payment: true, + export: true, + }, + files: { view: true, upload: true, edit: true, delete: true, manage_folders: true }, + email: { view: true, send: true, configure_account: true }, + reminders: { + view_own: true, + view_all: true, + create: true, + edit_own: true, + edit_all: true, + assign_others: true, + }, + calendar: { connect: true, view_events: true }, + reports: { view_dashboard: true, view_analytics: true, export: true }, + document_templates: { view: true, generate: true, manage: true }, + yachts: { view: true, create: true, edit: true, delete: true, transfer: true }, + companies: { view: true, create: true, edit: true, delete: true }, + memberships: { view: true, manage: true }, + reservations: { view: true, create: true, activate: true, cancel: true }, + admin: { + manage_users: true, + view_audit_log: true, + manage_settings: true, + manage_webhooks: true, + manage_reports: true, + manage_custom_fields: true, + manage_forms: true, + manage_tags: true, + system_backup: true, + permanently_delete_clients: true, + }, + residential_clients: { view: true, create: true, edit: true, delete: true }, + residential_interests: { + view: true, + create: true, + edit: true, + delete: true, + change_stage: true, + }, +}; + +export const DIRECTOR_PERMISSIONS: RolePermissions = { + clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, + interests: { + view: true, + create: true, + edit: true, + delete: true, + change_stage: true, + override_stage: true, + generate_eoi: true, + export: true, + }, + berths: { view: true, edit: true, import: true, manage_waiting_list: true }, + documents: { + view: true, + create: true, + edit: true, + send_for_signing: true, + upload_signed: true, + delete: true, + }, + expenses: { + view: true, + create: true, + edit: true, + delete: true, + export: true, + scan_receipt: true, + }, + invoices: { + view: true, + create: true, + edit: true, + delete: true, + send: true, + record_payment: true, + export: true, + }, + files: { view: true, upload: true, edit: true, delete: true, manage_folders: true }, + email: { view: true, send: true, configure_account: true }, + reminders: { + view_own: true, + view_all: true, + create: true, + edit_own: true, + edit_all: true, + assign_others: true, + }, + calendar: { connect: true, view_events: true }, + reports: { view_dashboard: true, view_analytics: true, export: true }, + document_templates: { view: true, generate: true, manage: true }, + yachts: { view: true, create: true, edit: true, delete: true, transfer: true }, + companies: { view: true, create: true, edit: true, delete: true }, + memberships: { view: true, manage: true }, + reservations: { view: true, create: true, activate: true, cancel: true }, + admin: { + manage_users: true, + view_audit_log: true, + manage_settings: true, + manage_webhooks: true, + manage_reports: true, + manage_custom_fields: true, + manage_forms: true, + manage_tags: true, + system_backup: false, + permanently_delete_clients: false, + }, + residential_clients: { view: true, create: true, edit: true, delete: true }, + residential_interests: { + view: true, + create: true, + edit: true, + delete: true, + change_stage: true, + }, +}; + +export const SALES_MANAGER_PERMISSIONS: RolePermissions = { + clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true }, + interests: { + view: true, + create: true, + edit: true, + delete: false, + change_stage: true, + override_stage: true, + generate_eoi: true, + export: true, + }, + berths: { view: true, edit: true, import: false, manage_waiting_list: true }, + documents: { + view: true, + create: true, + edit: true, + send_for_signing: true, + upload_signed: true, + delete: false, + }, + expenses: { + view: true, + create: true, + edit: true, + delete: false, + export: true, + scan_receipt: true, + }, + invoices: { + view: true, + create: true, + edit: true, + delete: false, + send: true, + record_payment: true, + export: true, + }, + files: { view: true, upload: true, edit: true, delete: false, manage_folders: true }, + email: { view: true, send: true, configure_account: true }, + reminders: { + view_own: true, + view_all: true, + create: true, + edit_own: true, + edit_all: true, + assign_others: true, + }, + calendar: { connect: true, view_events: true }, + reports: { view_dashboard: true, view_analytics: true, export: true }, + document_templates: { view: true, generate: true, manage: false }, + yachts: { view: true, create: true, edit: true, delete: false, transfer: true }, + companies: { view: true, create: true, edit: true, delete: false }, + memberships: { view: true, manage: true }, + reservations: { view: true, create: true, activate: true, cancel: true }, + admin: { + manage_users: false, + view_audit_log: false, + manage_settings: false, + manage_webhooks: false, + manage_reports: false, + manage_custom_fields: false, + manage_forms: false, + manage_tags: true, + system_backup: false, + permanently_delete_clients: false, + }, + residential_clients: { view: false, create: false, edit: false, delete: false }, + residential_interests: { + view: false, + create: false, + edit: false, + delete: false, + change_stage: false, + }, +}; + +export const SALES_AGENT_PERMISSIONS: RolePermissions = { + clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true }, + interests: { + view: true, + create: true, + edit: true, + delete: false, + change_stage: true, + override_stage: true, + generate_eoi: true, + export: true, + }, + berths: { view: true, edit: true, import: false, manage_waiting_list: true }, + documents: { + view: true, + create: true, + edit: true, + send_for_signing: true, + upload_signed: true, + delete: false, + }, + expenses: { + view: true, + create: true, + edit: true, + delete: false, + export: true, + scan_receipt: true, + }, + invoices: { + view: true, + create: true, + edit: true, + delete: false, + send: true, + record_payment: true, + export: true, + }, + files: { view: true, upload: true, edit: false, delete: false, manage_folders: false }, + email: { view: true, send: true, configure_account: true }, + reminders: { + view_own: true, + view_all: false, + create: true, + edit_own: true, + edit_all: false, + assign_others: false, + }, + calendar: { connect: true, view_events: true }, + reports: { view_dashboard: true, view_analytics: true, export: true }, + document_templates: { view: true, generate: true, manage: false }, + yachts: { view: true, create: true, edit: true, delete: false, transfer: false }, + companies: { view: true, create: true, edit: false, delete: false }, + memberships: { view: true, manage: false }, + reservations: { view: true, create: true, activate: true, cancel: false }, + admin: { + manage_users: false, + view_audit_log: false, + manage_settings: false, + manage_webhooks: false, + manage_reports: false, + manage_custom_fields: false, + manage_forms: false, + manage_tags: true, + system_backup: false, + permanently_delete_clients: false, + }, + residential_clients: { view: false, create: false, edit: false, delete: false }, + residential_interests: { + view: false, + create: false, + edit: false, + delete: false, + change_stage: false, + }, +}; + +export const VIEWER_PERMISSIONS: RolePermissions = { + clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false }, + interests: { + view: true, + create: false, + edit: false, + delete: false, + change_stage: false, + override_stage: false, + generate_eoi: false, + export: false, + }, + berths: { view: true, edit: false, import: false, manage_waiting_list: false }, + documents: { + view: true, + create: false, + edit: false, + send_for_signing: false, + upload_signed: false, + delete: false, + }, + expenses: { + view: true, + create: false, + edit: false, + delete: false, + export: false, + scan_receipt: false, + }, + invoices: { + view: true, + create: false, + edit: false, + delete: false, + send: false, + record_payment: false, + export: false, + }, + files: { view: true, upload: false, edit: false, delete: false, manage_folders: false }, + email: { view: true, send: false, configure_account: false }, + reminders: { + view_own: true, + view_all: false, + create: false, + edit_own: false, + edit_all: false, + assign_others: false, + }, + calendar: { connect: false, view_events: true }, + reports: { view_dashboard: true, view_analytics: false, export: false }, + document_templates: { view: true, generate: false, manage: false }, + yachts: { view: true, create: false, edit: false, delete: false, transfer: false }, + companies: { view: true, create: false, edit: false, delete: false }, + memberships: { view: true, manage: false }, + reservations: { view: true, create: false, activate: false, cancel: false }, + admin: { + manage_users: false, + view_audit_log: false, + manage_settings: false, + manage_webhooks: false, + manage_reports: false, + manage_custom_fields: false, + manage_forms: false, + manage_tags: false, + system_backup: false, + permanently_delete_clients: false, + }, + residential_clients: { view: false, create: false, edit: false, delete: false }, + residential_interests: { + view: false, + create: false, + edit: false, + delete: false, + change_stage: false, + }, +}; + +// Residential Partner — for an outside party who handles residential +// inquiries on the marina's behalf. Sees only the residential pages and +// nothing else; can't see marina clients, yachts, berths, EOIs, etc. +export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = { + clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false }, + interests: { + view: false, + create: false, + edit: false, + delete: false, + change_stage: false, + override_stage: false, + generate_eoi: false, + export: false, + }, + berths: { view: false, edit: false, import: false, manage_waiting_list: false }, + documents: { + view: false, + create: false, + edit: false, + send_for_signing: false, + upload_signed: false, + delete: false, + }, + expenses: { + view: false, + create: false, + edit: false, + delete: false, + export: false, + scan_receipt: false, + }, + invoices: { + view: false, + create: false, + edit: false, + delete: false, + send: false, + record_payment: false, + export: false, + }, + files: { view: false, upload: false, edit: false, delete: false, manage_folders: false }, + email: { view: false, send: false, configure_account: false }, + reminders: { + view_own: true, + view_all: false, + create: true, + edit_own: true, + edit_all: false, + assign_others: false, + }, + calendar: { connect: false, view_events: false }, + reports: { view_dashboard: false, view_analytics: false, export: false }, + document_templates: { view: false, generate: false, manage: false }, + yachts: { view: false, create: false, edit: false, delete: false, transfer: false }, + companies: { view: false, create: false, edit: false, delete: false }, + memberships: { view: false, manage: false }, + reservations: { view: false, create: false, activate: false, cancel: false }, + admin: { + manage_users: false, + view_audit_log: false, + manage_settings: false, + manage_webhooks: false, + manage_reports: false, + manage_custom_fields: false, + manage_forms: false, + manage_tags: false, + system_backup: false, + permanently_delete_clients: false, + }, + residential_clients: { view: true, create: true, edit: true, delete: false }, + residential_interests: { + view: true, + create: true, + edit: true, + delete: false, + change_stage: true, + }, +}; diff --git a/src/lib/db/seed-synthetic-data.ts b/src/lib/db/seed-synthetic-data.ts new file mode 100644 index 0000000..5b7e7ab --- /dev/null +++ b/src/lib/db/seed-synthetic-data.ts @@ -0,0 +1,764 @@ +/** + * Per-port synthetic seed builder for "every pipeline stage" coverage. + * + * The realistic seed in `seed-data.ts` mirrors the legacy NocoDB shape; + * this one is purpose-built for thoroughly exercising the CRM. Every + * pipeline stage gets at least one client, plus a handful of edge-case + * fixtures (multi-interest, signed-EOI, archived with metadata, hard- + * delete-eligible, company member, yacht owner). + * + * Berths come from the same NocoDB snapshot so the public berth API + * still has data; the synthetic clients link to specific moorings so + * the under_offer / sold derivations are deterministic. + * + * Idempotent: skips if the port already has clients seeded. + * + * Run via `pnpm db:seed:synthetic`. + */ + +import { eq } from 'drizzle-orm'; + +import { db } from './index'; +import { withTransaction } from './utils'; +import { + clients, + clientContacts, + clientAddresses, + companies, + companyMemberships, + companyAddresses, + yachts, + yachtOwnershipHistory, + berths, + berthReservations, + interests, + interestBerths, +} from './schema'; +import { residentialClients, residentialInterests } from './schema'; +import { SUPER_ADMIN_USER_ID } from './seed-bootstrap'; +import berthSnapshot from './seed-data/berths.json'; +import type { PipelineStage } from '@/lib/constants'; +import type { ArchiveMetadata } from '@/lib/services/client-archive.service'; + +type SeedBerth = { + legacyId: number; + mooringNumber: string; + area: string | null; + status: 'available' | 'under_offer' | 'sold'; + lengthFt: number | null; + widthFt: number | null; + draftFt: number | null; + lengthM: number | null; + widthM: number | null; + draftM: number | null; + widthIsMinimum: boolean; + nominalBoatSize: number | null; + nominalBoatSizeM: number | null; + waterDepth: number | null; + waterDepthM: number | null; + waterDepthIsMinimum: boolean; + sidePontoon: string | null; + powerCapacity: number | null; + voltage: number | null; + mooringType: string | null; + cleatType: string | null; + cleatCapacity: string | null; + bollardType: string | null; + bollardCapacity: string | null; + access: string | null; + price: number | null; + bowFacing: string | null; + berthApproved: boolean; + statusOverrideMode: string | null; +}; +const BERTH_SNAPSHOT = berthSnapshot as SeedBerth[]; + +function daysAgo(n: number): Date { + return new Date(Date.now() - n * 86_400_000); +} + +export interface SyntheticSeedSummary { + berths: number; + clients: number; + interests: number; + companies: number; + yachts: number; + residentialClients: number; +} + +interface SyntheticClientSpec { + /** Used as a name suffix so test selectors can target it deterministically. */ + tag: string; + fullName: string; + email: string; + phone: string; + countryIso: string; + city: string; + street: string; + postalCode: string; + /** Pipeline stage of the (single) interest. Omit for archived-only clients. */ + stage?: PipelineStage; + /** Index into BERTH_SNAPSHOT for the primary linked berth. */ + berthIdx?: number; + /** Mark interest as won/lost when stage = completed. */ + outcome?: 'won' | 'lost_unqualified' | 'lost_no_response'; + /** Archive the CLIENT after creation. When 'rich', fabricate + * archive_metadata so the smart-restore wizard surfaces reversals. */ + archive?: 'simple' | 'rich'; +} + +/** + * Each spec produces exactly one client + one interest at the given + * stage. Clients are tagged so a Playwright test can locate them by + * either name (full name) or tag (substring after the dash). + * + * Berth indices map deterministically into the NocoDB snapshot which is + * pre-sorted: idx 0..4 available, 5..9 under_offer, 10..11 sold. + */ +const PIPELINE_CLIENTS: SyntheticClientSpec[] = [ + { + tag: 'open', + fullName: 'Olivia Open — open', + email: 'olivia.open@test.local', + phone: '+1 555 010 0001', + countryIso: 'GB', + city: 'London', + street: '1 Open Lane', + postalCode: 'OP1 1OP', + stage: 'open', + // Open stage: no berth link yet + }, + { + tag: 'details', + fullName: 'Daniel Details — details_sent', + email: 'daniel.details@test.local', + phone: '+1 555 010 0002', + countryIso: 'US', + city: 'Miami', + street: '2 Brochure Way', + postalCode: '33101', + stage: 'details_sent', + berthIdx: 0, + }, + { + tag: 'comms', + fullName: 'Carla Communicating — in_communication', + email: 'carla.comms@test.local', + phone: '+1 555 010 0003', + countryIso: 'ES', + city: 'Palma', + street: '3 Reply Street', + postalCode: '07012', + stage: 'in_communication', + berthIdx: 5, + }, + { + tag: 'eoi-sent', + fullName: 'Eric EoiSent — eoi_sent', + email: 'eric.eoisent@test.local', + phone: '+1 555 010 0004', + countryIso: 'IT', + city: 'Genoa', + street: '4 Envelope Plaza', + postalCode: '16124', + stage: 'eoi_sent', + berthIdx: 6, + }, + { + tag: 'eoi-signed', + fullName: 'Sara EoiSigned — eoi_signed', + email: 'sara.eoisigned@test.local', + phone: '+1 555 010 0005', + countryIso: 'FR', + city: 'Nice', + street: '5 Signed Avenue', + postalCode: '06300', + stage: 'eoi_signed', + berthIdx: 7, + }, + { + tag: 'deposit', + fullName: 'Dario Deposit — deposit_10pct', + email: 'dario.deposit@test.local', + phone: '+1 555 010 0006', + countryIso: 'GR', + city: 'Athens', + street: '6 Deposit Quay', + postalCode: '10558', + stage: 'deposit_10pct', + berthIdx: 8, + }, + { + tag: 'contract-sent', + fullName: 'Connor ContractSent — contract_sent', + email: 'connor.contract@test.local', + phone: '+1 555 010 0007', + countryIso: 'IE', + city: 'Dublin', + street: '7 Contract Row', + postalCode: 'D02 E2X3', + stage: 'contract_sent', + berthIdx: 9, + }, + { + tag: 'contract-signed', + fullName: 'Carmen ContractSigned — contract_signed', + email: 'carmen.signed@test.local', + phone: '+1 555 010 0008', + countryIso: 'PT', + city: 'Lisbon', + street: '8 Notary Square', + postalCode: '1100-001', + stage: 'contract_signed', + berthIdx: 4, + }, + { + tag: 'completed-won', + fullName: 'Carlos Completed — completed (won)', + email: 'carlos.complete@test.local', + phone: '+1 555 010 0009', + countryIso: 'PA', + city: 'Panama City', + street: '9 Owner Lane', + postalCode: '0801', + stage: 'completed', + berthIdx: 10, + outcome: 'won', + }, + { + tag: 'completed-lost', + fullName: 'Lara LostLead — completed (lost)', + email: 'lara.lost@test.local', + phone: '+1 555 010 0010', + countryIso: 'DE', + city: 'Hamburg', + street: '10 Other Marina', + postalCode: '20457', + stage: 'completed', + berthIdx: 1, + outcome: 'lost_unqualified', + }, + { + tag: 'archived-simple', + fullName: 'Anna ArchivedSimple — archived', + email: 'anna.archived@test.local', + phone: '+1 555 010 0011', + countryIso: 'NL', + city: 'Amsterdam', + street: '11 Quiet Path', + postalCode: '1011', + archive: 'simple', + }, + { + tag: 'archived-rich', + fullName: 'Rita ArchivedRich — archived w/ metadata', + email: 'rita.archivedrich@test.local', + phone: '+1 555 010 0012', + countryIso: 'BE', + city: 'Antwerp', + street: '12 Rich Metadata Blvd', + postalCode: '2000', + archive: 'rich', + }, +]; + +export async function seedSyntheticPortData( + portId: string, + portSlug: string, +): Promise { + const existing = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.portId, portId)) + .limit(1); + if (existing.length > 0) { + console.log(` [${portSlug}] already seeded (clients exist), skipping.`); + return null; + } + + return withTransaction(async (tx) => { + // ── 1. Berths ─────────────────────────────────────────────────────────── + // Same NocoDB snapshot as the realistic seed so the public map keeps + // working. We override status for the moorings we link to so the + // dossier UI shows the expected stake levels (under_offer / sold). + const berthRows = await tx + .insert(berths) + .values( + BERTH_SNAPSHOT.map((b) => ({ + portId, + mooringNumber: b.mooringNumber, + area: b.area, + status: b.status, + lengthFt: b.lengthFt != null ? String(b.lengthFt) : null, + widthFt: b.widthFt != null ? String(b.widthFt) : null, + draftFt: b.draftFt != null ? String(b.draftFt) : null, + lengthM: b.lengthM != null ? String(b.lengthM) : null, + widthM: b.widthM != null ? String(b.widthM) : null, + draftM: b.draftM != null ? String(b.draftM) : null, + widthIsMinimum: b.widthIsMinimum, + nominalBoatSize: b.nominalBoatSize != null ? String(b.nominalBoatSize) : null, + nominalBoatSizeM: b.nominalBoatSizeM != null ? String(b.nominalBoatSizeM) : null, + waterDepth: b.waterDepth != null ? String(b.waterDepth) : null, + waterDepthM: b.waterDepthM != null ? String(b.waterDepthM) : null, + waterDepthIsMinimum: b.waterDepthIsMinimum, + sidePontoon: b.sidePontoon, + powerCapacity: b.powerCapacity != null ? String(b.powerCapacity) : null, + voltage: b.voltage != null ? String(b.voltage) : null, + mooringType: b.mooringType, + cleatType: b.cleatType, + cleatCapacity: b.cleatCapacity, + bollardType: b.bollardType, + bollardCapacity: b.bollardCapacity, + access: b.access, + price: b.price != null ? String(b.price) : null, + priceCurrency: 'USD', + bowFacing: b.bowFacing, + berthApproved: b.berthApproved, + statusOverrideMode: b.statusOverrideMode, + tenureType: 'permanent' as const, + })), + ) + .returning({ id: berths.id, status: berths.status, mooringNumber: berths.mooringNumber }); + + // ── 2. Companies (one active, one with multiple memberships) ──────────── + const companyRows = await tx + .insert(companies) + .values([ + { + portId, + name: 'Test Charter Co.', + legalName: 'Test Charter Company Ltd.', + taxId: `TC-${portSlug}-001`, + registrationNumber: 'TC-2024-0001', + incorporationCountryIso: 'GB', + incorporationDate: new Date('2024-01-01'), + status: 'active', + billingEmail: 'billing@testcharter.test.local', + notes: 'Synthetic test company - has multiple member clients.', + }, + ]) + .returning({ id: companies.id, name: companies.name }); + const charterCoId = companyRows[0]!.id; + + await tx.insert(companyAddresses).values([ + { + companyId: charterCoId, + portId, + label: 'Head Office', + streetAddress: '1 Test Street', + city: 'London', + subdivisionIso: null, + postalCode: 'W1A 1AA', + countryIso: 'GB', + isPrimary: true, + }, + ]); + + // ── 3. Clients ────────────────────────────────────────────────────────── + const clientRows = await tx + .insert(clients) + .values( + PIPELINE_CLIENTS.map((spec) => ({ + portId, + fullName: spec.fullName, + nationalityIso: spec.countryIso, + preferredContactMethod: 'email' as const, + preferredLanguage: 'en', + source: 'manual' as const, + })), + ) + .returning({ id: clients.id, fullName: clients.fullName }); + + const idByTag = new Map(); + PIPELINE_CLIENTS.forEach((spec, i) => idByTag.set(spec.tag, clientRows[i]!.id)); + + // Contacts + const contactValues: Array = []; + PIPELINE_CLIENTS.forEach((spec, i) => { + const cid = clientRows[i]!.id; + contactValues.push({ + clientId: cid, + channel: 'email', + value: spec.email, + label: 'primary', + isPrimary: true, + }); + contactValues.push({ + clientId: cid, + channel: 'phone', + value: spec.phone, + label: 'primary', + isPrimary: false, + }); + }); + await tx.insert(clientContacts).values(contactValues); + + // Addresses + await tx.insert(clientAddresses).values( + PIPELINE_CLIENTS.map((spec, i) => ({ + clientId: clientRows[i]!.id, + portId, + label: 'Primary', + streetAddress: spec.street, + city: spec.city, + subdivisionIso: null, + postalCode: spec.postalCode, + countryIso: spec.countryIso, + isPrimary: true, + })), + ); + + // ── 4. Yachts (the completed-won client gets one) ─────────────────────── + const completedWonId = idByTag.get('completed-won')!; + const charterYachtRow = await tx + .insert(yachts) + .values([ + { + portId, + name: 'Test Wanderer', + hullNumber: 'TW-001', + flag: 'PA', + yearBuilt: 2018, + builder: 'Synthetic Yard', + model: 'Cruiser 50', + lengthFt: '50', + widthFt: '15', + draftFt: '6', + currentOwnerType: 'client' as const, + currentOwnerId: completedWonId, + status: 'active' as const, + notes: 'Owned by the completed-won test client.', + }, + { + portId, + name: 'Charter Co. Flagship', + hullNumber: 'CC-FLAG-001', + flag: 'GB', + yearBuilt: 2022, + builder: 'Synthetic Yard', + model: 'Sailing Yacht 55', + lengthFt: '55', + widthFt: '17', + draftFt: '7', + currentOwnerType: 'company' as const, + currentOwnerId: charterCoId, + status: 'active' as const, + notes: 'Owned by Test Charter Co.', + }, + ]) + .returning({ id: yachts.id, name: yachts.name }); + + await tx.insert(yachtOwnershipHistory).values([ + { + yachtId: charterYachtRow[0]!.id, + ownerType: 'client', + ownerId: completedWonId, + startDate: daysAgo(180), + endDate: null, + transferReason: null, + transferNotes: null, + createdBy: SUPER_ADMIN_USER_ID, + }, + { + yachtId: charterYachtRow[1]!.id, + ownerType: 'company', + ownerId: charterCoId, + startDate: daysAgo(365), + endDate: null, + transferReason: null, + transferNotes: null, + createdBy: SUPER_ADMIN_USER_ID, + }, + ]); + + // ── 5. Memberships (link a couple of clients to Test Charter Co.) ────── + const dirClientId = idByTag.get('contract-sent')!; + const officerClientId = idByTag.get('eoi-signed')!; + await tx.insert(companyMemberships).values([ + { + companyId: charterCoId, + clientId: dirClientId, + role: 'director', + roleDetail: 'Test director', + startDate: daysAgo(120), + endDate: null, + isPrimary: true, + }, + { + companyId: charterCoId, + clientId: officerClientId, + role: 'officer', + roleDetail: 'Test officer', + startDate: daysAgo(90), + endDate: null, + isPrimary: false, + }, + ]); + + // ── 6. Berth status overrides for linked moorings ─────────────────────── + // Match the dossier classification to the berth's pipeline stage. + // For under_offer-wave clients (eoi_sent → contract_sent), force the + // berth to under_offer. For completed-won, mark the berth sold. + const stageToBerthStatus = ( + stage: PipelineStage | undefined, + ): 'available' | 'under_offer' | 'sold' | null => { + if (!stage) return null; + if (stage === 'completed') return 'sold'; + if ( + stage === 'eoi_sent' || + stage === 'eoi_signed' || + stage === 'deposit_10pct' || + stage === 'contract_sent' || + stage === 'contract_signed' + ) { + return 'under_offer'; + } + return null; + }; + + for (const spec of PIPELINE_CLIENTS) { + if (spec.berthIdx === undefined) continue; + const newStatus = stageToBerthStatus(spec.stage); + if (!newStatus) continue; + const berthId = berthRows[spec.berthIdx]!.id; + await tx.update(berths).set({ status: newStatus }).where(eq(berths.id, berthId)); + } + + // ── 7. Interests + interest_berths ────────────────────────────────────── + let interestCount = 0; + for (const spec of PIPELINE_CLIENTS) { + if (!spec.stage) continue; + const clientId = idByTag.get(spec.tag)!; + const stageDaysAgoMap: Record = { + open: 1, + details_sent: 5, + in_communication: 10, + eoi_sent: 20, + eoi_signed: 35, + deposit_10pct: 60, + contract_sent: 80, + contract_signed: 110, + completed: spec.outcome === 'won' ? 200 : 60, + }; + const ageDays = stageDaysAgoMap[spec.stage]; + const yachtId = spec.tag === 'completed-won' ? charterYachtRow[0]!.id : null; + + const [intRow] = await tx + .insert(interests) + .values({ + portId, + clientId, + yachtId, + pipelineStage: spec.stage, + leadCategory: + spec.stage === 'open' + ? 'general_interest' + : spec.stage === 'details_sent' || spec.stage === 'in_communication' + ? 'specific_qualified' + : 'hot_lead', + source: 'manual' as const, + dateFirstContact: daysAgo(ageDays), + dateLastContact: daysAgo(Math.max(0, ageDays - 2)), + dateEoiSent: + spec.stage === 'eoi_sent' || + spec.stage === 'eoi_signed' || + spec.stage === 'deposit_10pct' || + spec.stage === 'contract_sent' || + spec.stage === 'contract_signed' || + spec.stage === 'completed' + ? daysAgo(Math.max(0, ageDays - 5)) + : null, + dateEoiSigned: + spec.stage === 'eoi_signed' || + spec.stage === 'deposit_10pct' || + spec.stage === 'contract_sent' || + spec.stage === 'contract_signed' || + spec.stage === 'completed' + ? daysAgo(Math.max(0, ageDays - 10)) + : null, + eoiStatus: + spec.stage === 'eoi_sent' + ? 'waiting_for_signatures' + : spec.stage === 'eoi_signed' || + spec.stage === 'deposit_10pct' || + spec.stage === 'contract_sent' || + spec.stage === 'contract_signed' || + spec.stage === 'completed' + ? 'signed' + : null, + outcome: spec.outcome ?? null, + outcomeAt: spec.outcome ? daysAgo(7) : null, + outcomeReason: + spec.outcome === 'lost_unqualified' ? 'Synthetic test: not qualified.' : null, + }) + .returning({ id: interests.id }); + interestCount += 1; + + if (spec.berthIdx !== undefined) { + const berthId = berthRows[spec.berthIdx]!.id; + await tx.insert(interestBerths).values({ + interestId: intRow!.id, + berthId, + isPrimary: true, + isSpecificInterest: true, + isInEoiBundle: spec.stage !== 'open' && spec.stage !== 'details_sent', + addedBy: SUPER_ADMIN_USER_ID, + }); + } + } + + // ── 8. Multi-interest client ──────────────────────────────────────────── + // Adds a second interest to "carla.comms" so the dossier shows + // multiple deals on the same client. + const carlaId = idByTag.get('comms')!; + const [secondInt] = await tx + .insert(interests) + .values({ + portId, + clientId: carlaId, + yachtId: null, + pipelineStage: 'open', + leadCategory: 'general_interest', + source: 'website' as const, + dateFirstContact: daysAgo(2), + dateLastContact: daysAgo(1), + }) + .returning({ id: interests.id }); + await tx.insert(interestBerths).values({ + interestId: secondInt!.id, + berthId: berthRows[2]!.id, + isPrimary: true, + isSpecificInterest: true, + isInEoiBundle: false, + addedBy: SUPER_ADMIN_USER_ID, + }); + + // ── 9. Reservations ───────────────────────────────────────────────────── + // One active reservation on the under_offer berth held by Carla, + // one cancelled on an available berth. + // berthReservations requires a yacht — wire both to the charter co. + // flagship since Carla / Olivia don't own yachts yet. + const sharedYachtId = charterYachtRow[1]!.id; + await tx.insert(berthReservations).values([ + { + portId, + berthId: berthRows[5]!.id, + clientId: carlaId, + yachtId: sharedYachtId, + startDate: daysAgo(10), + endDate: null, + status: 'active', + notes: 'Synthetic active reservation.', + createdBy: SUPER_ADMIN_USER_ID, + }, + { + portId, + berthId: berthRows[3]!.id, + clientId: idByTag.get('open')!, + yachtId: sharedYachtId, + startDate: daysAgo(30), + endDate: daysAgo(20), + status: 'cancelled', + notes: 'Synthetic cancelled reservation.', + createdBy: SUPER_ADMIN_USER_ID, + }, + ]); + + // ── 10. Apply archive metadata for Anna + Rita ────────────────────────── + const annaId = idByTag.get('archived-simple')!; + await tx + .update(clients) + .set({ + archivedAt: daysAgo(30), + // archived_by FK references the better-auth user table; the + // synthetic super-admin is just a profile placeholder so we + // leave this null. Field is set to the actual operator id by + // the smart-archive service in production code paths. + archivedBy: null, + archiveReason: '', + archiveMetadata: null, + }) + .where(eq(clients.id, annaId)); + + // Rich-archive: fabricate a metadata payload that the smart-restore + // wizard will surface as auto-reversible (berth still available) + + // opt-in-to-undo (yacht transferred). + const ritaId = idByTag.get('archived-rich')!; + const richMetadata: ArchiveMetadata = { + decisions: [ + { + kind: 'berth_released', + refId: berthRows[2]!.id, + detail: { mooringNumber: berthRows[2]!.mooringNumber }, + }, + { + kind: 'yacht_transferred', + refId: charterYachtRow[1]!.id, + detail: { newOwnerType: 'company', newOwnerId: charterCoId }, + }, + ], + decidedAt: daysAgo(20).toISOString(), + decidedBy: SUPER_ADMIN_USER_ID, + reason: 'Synthetic rich-archive for restore wizard testing.', + }; + await tx + .update(clients) + .set({ + archivedAt: daysAgo(20), + archivedBy: null, + archiveReason: richMetadata.reason, + archiveMetadata: richMetadata, + }) + .where(eq(clients.id, ritaId)); + + // ── 11. Residential pipeline (one per stage cluster) ──────────────────── + const residentialRows = await tx + .insert(residentialClients) + .values([ + { + portId, + fullName: 'Robert Resident', + email: 'robert.resident@test.local', + phone: '+1 555 020 0001', + source: 'website' as const, + notes: 'Synthetic residential lead.', + }, + { + portId, + fullName: 'Rina Resident', + email: 'rina.resident@test.local', + phone: '+1 555 020 0002', + source: 'referral' as const, + notes: 'Synthetic residential lead — qualified.', + }, + ]) + .returning({ id: residentialClients.id }); + + await tx.insert(residentialInterests).values([ + { + portId, + residentialClientId: residentialRows[0]!.id, + pipelineStage: 'new', + notes: 'Synthetic residential interest at "new" stage.', + dateFirstContact: daysAgo(2), + }, + { + portId, + residentialClientId: residentialRows[1]!.id, + pipelineStage: 'contacted', + notes: 'Synthetic residential interest at "contacted" stage.', + dateFirstContact: daysAgo(7), + dateLastContact: daysAgo(2), + }, + ]); + + return { + berths: berthRows.length, + clients: clientRows.length, + interests: interestCount + 1, // +1 for Carla's second interest + companies: 1, + yachts: charterYachtRow.length, + residentialClients: residentialRows.length, + } satisfies SyntheticSeedSummary; + }); +} diff --git a/src/lib/db/seed-synthetic.ts b/src/lib/db/seed-synthetic.ts new file mode 100644 index 0000000..7f965b6 --- /dev/null +++ b/src/lib/db/seed-synthetic.ts @@ -0,0 +1,55 @@ +/** + * Synthetic seed (the "every pipeline stage" fixture). + * + * Bootstraps the same ports/roles/profile as `seed.ts` then loads + * `seedSyntheticPortData()` per port — 12 clients, one per pipeline + * stage plus archive variants, designed for thoroughly testing the + * CRM end-to-end. + * + * Use the realistic seed (`pnpm db:seed`) for shapes that mirror the + * production NocoDB clone. + * + * Run with: pnpm db:seed:synthetic + */ + +import 'dotenv/config'; +import { seedBootstrap } from './seed-bootstrap'; +import { seedSyntheticPortData, type SyntheticSeedSummary } from './seed-synthetic-data'; + +async function seed() { + console.log('Seeding Port Nimara CRM (synthetic test fixture)...'); + + const portIds = await seedBootstrap(); + + console.log(''); + console.log('Seeding per-port synthetic fixtures...'); + + const summaries: Array<{ name: string; summary: SyntheticSeedSummary | null }> = []; + for (const p of portIds) { + console.log(` [${p.slug}] seeding synthetic data...`); + const summary = await seedSyntheticPortData(p.id, p.slug); + summaries.push({ name: p.name, summary }); + } + + console.log(''); + console.log('─── Summary ───────────────────────────────────────────────'); + for (const s of summaries) { + if (s.summary === null) { + console.log(` ✓ Port "${s.name}" - already seeded (skipped)`); + } else { + const x = s.summary; + console.log( + ` ✓ Port "${s.name}" - ${x.berths} berths, ${x.clients} clients, ${x.companies} companies, ${x.yachts} yachts, ${x.interests} interests, ${x.residentialClients} residential clients`, + ); + } + } + console.log(''); + console.log('Synthetic seed complete!'); + + process.exit(0); +} + +seed().catch((err) => { + console.error('Synthetic seed failed:', err); + process.exit(1); +}); diff --git a/src/lib/db/seed.ts b/src/lib/db/seed.ts index 57b8ddd..1b7c132 100644 --- a/src/lib/db/seed.ts +++ b/src/lib/db/seed.ts @@ -1,654 +1,26 @@ /** - * Seed script for Port Nimara CRM. + * Realistic seed (the "production-shaped" fixture). * - * Top-level orchestrator: - * 1. Create the operational ports (idempotent): - * - Port Nimara (primary install - the real marina) - * - Port Amador (secondary, kept for multi-tenant isolation tests - * and as scaffolding for a future Panama install) - * 2. Create 5 system roles with full permission maps - * 3. Create the super admin user profile placeholder (matt@portnimara.com) - * 4. For each port, call `seedPortData(portId, portSlug)` from seed-data.ts - * to produce the realistic multi-cardinality fixture - * (117 berths from the NocoDB snapshot, plus clients, companies, yachts, - * memberships, interests, reservations, ownership-transfer history). - * 5. Print a summary. + * Bootstraps ports + roles + super-admin profile, then runs + * `seedPortData()` per port to load the NocoDB-shaped multi-cardinality + * fixture (117 berths, 8 clients, 3 companies, 12 yachts, 15 interests, + * 8 reservations). + * + * For a focused test fixture covering every pipeline stage + archive + * variants, use `pnpm db:seed:synthetic` instead. * * Run with: pnpm db:seed */ import 'dotenv/config'; -import { eq } from 'drizzle-orm'; -import { db } from './index'; -import { ports } from './schema/ports'; -import { roles, userProfiles } from './schema/users'; -import type { RolePermissions } from './schema/users'; +import { seedBootstrap } from './seed-bootstrap'; import { seedPortData, type SeedSummary } from './seed-data'; -// ─── Permission Maps ───────────────────────────────────────────────────────── - -const ALL_PERMISSIONS: RolePermissions = { - clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, - interests: { - view: true, - create: true, - edit: true, - delete: true, - change_stage: true, - override_stage: true, - generate_eoi: true, - export: true, - }, - berths: { view: true, edit: true, import: true, manage_waiting_list: true }, - documents: { - view: true, - create: true, - edit: true, - send_for_signing: true, - upload_signed: true, - delete: true, - }, - expenses: { - view: true, - create: true, - edit: true, - delete: true, - export: true, - scan_receipt: true, - }, - invoices: { - view: true, - create: true, - edit: true, - delete: true, - send: true, - record_payment: true, - export: true, - }, - files: { view: true, upload: true, edit: true, delete: true, manage_folders: true }, - email: { view: true, send: true, configure_account: true }, - reminders: { - view_own: true, - view_all: true, - create: true, - edit_own: true, - edit_all: true, - assign_others: true, - }, - calendar: { connect: true, view_events: true }, - reports: { view_dashboard: true, view_analytics: true, export: true }, - document_templates: { view: true, generate: true, manage: true }, - yachts: { view: true, create: true, edit: true, delete: true, transfer: true }, - companies: { view: true, create: true, edit: true, delete: true }, - memberships: { view: true, manage: true }, - reservations: { view: true, create: true, activate: true, cancel: true }, - admin: { - manage_users: true, - view_audit_log: true, - manage_settings: true, - manage_webhooks: true, - manage_reports: true, - manage_custom_fields: true, - manage_forms: true, - manage_tags: true, - system_backup: true, - permanently_delete_clients: true, - }, - residential_clients: { view: true, create: true, edit: true, delete: true }, - residential_interests: { - view: true, - create: true, - edit: true, - delete: true, - change_stage: true, - }, -}; - -const DIRECTOR_PERMISSIONS: RolePermissions = { - clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, - interests: { - view: true, - create: true, - edit: true, - delete: true, - change_stage: true, - override_stage: true, - generate_eoi: true, - export: true, - }, - berths: { view: true, edit: true, import: true, manage_waiting_list: true }, - documents: { - view: true, - create: true, - edit: true, - send_for_signing: true, - upload_signed: true, - delete: true, - }, - expenses: { - view: true, - create: true, - edit: true, - delete: true, - export: true, - scan_receipt: true, - }, - invoices: { - view: true, - create: true, - edit: true, - delete: true, - send: true, - record_payment: true, - export: true, - }, - files: { view: true, upload: true, edit: true, delete: true, manage_folders: true }, - email: { view: true, send: true, configure_account: true }, - reminders: { - view_own: true, - view_all: true, - create: true, - edit_own: true, - edit_all: true, - assign_others: true, - }, - calendar: { connect: true, view_events: true }, - reports: { view_dashboard: true, view_analytics: true, export: true }, - document_templates: { view: true, generate: true, manage: true }, - yachts: { view: true, create: true, edit: true, delete: true, transfer: true }, - companies: { view: true, create: true, edit: true, delete: true }, - memberships: { view: true, manage: true }, - reservations: { view: true, create: true, activate: true, cancel: true }, - admin: { - manage_users: true, - view_audit_log: true, - manage_settings: true, - manage_webhooks: true, - manage_reports: true, - manage_custom_fields: true, - manage_forms: true, - manage_tags: true, - system_backup: false, - permanently_delete_clients: false, - }, - residential_clients: { view: true, create: true, edit: true, delete: true }, - residential_interests: { - view: true, - create: true, - edit: true, - delete: true, - change_stage: true, - }, -}; - -const SALES_MANAGER_PERMISSIONS: RolePermissions = { - clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true }, - interests: { - view: true, - create: true, - edit: true, - delete: false, - change_stage: true, - override_stage: true, - generate_eoi: true, - export: true, - }, - berths: { view: true, edit: true, import: false, manage_waiting_list: true }, - documents: { - view: true, - create: true, - edit: true, - send_for_signing: true, - upload_signed: true, - delete: false, - }, - expenses: { - view: true, - create: true, - edit: true, - delete: false, - export: true, - scan_receipt: true, - }, - invoices: { - view: true, - create: true, - edit: true, - delete: false, - send: true, - record_payment: true, - export: true, - }, - files: { view: true, upload: true, edit: true, delete: false, manage_folders: true }, - email: { view: true, send: true, configure_account: true }, - reminders: { - view_own: true, - view_all: true, - create: true, - edit_own: true, - edit_all: true, - assign_others: true, - }, - calendar: { connect: true, view_events: true }, - reports: { view_dashboard: true, view_analytics: true, export: true }, - document_templates: { view: true, generate: true, manage: false }, - yachts: { view: true, create: true, edit: true, delete: false, transfer: true }, - companies: { view: true, create: true, edit: true, delete: false }, - memberships: { view: true, manage: true }, - reservations: { view: true, create: true, activate: true, cancel: true }, - admin: { - manage_users: false, - view_audit_log: false, - manage_settings: false, - manage_webhooks: false, - manage_reports: false, - manage_custom_fields: false, - manage_forms: false, - manage_tags: true, - system_backup: false, - permanently_delete_clients: false, - }, - residential_clients: { view: false, create: false, edit: false, delete: false }, - residential_interests: { - view: false, - create: false, - edit: false, - delete: false, - change_stage: false, - }, -}; - -const SALES_AGENT_PERMISSIONS: RolePermissions = { - clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true }, - interests: { - view: true, - create: true, - edit: true, - delete: false, - change_stage: true, - override_stage: true, - generate_eoi: true, - export: true, - }, - berths: { view: true, edit: true, import: false, manage_waiting_list: true }, - documents: { - view: true, - create: true, - edit: true, - send_for_signing: true, - upload_signed: true, - delete: false, - }, - expenses: { - view: true, - create: true, - edit: true, - delete: false, - export: true, - scan_receipt: true, - }, - invoices: { - view: true, - create: true, - edit: true, - delete: false, - send: true, - record_payment: true, - export: true, - }, - files: { view: true, upload: true, edit: false, delete: false, manage_folders: false }, - email: { view: true, send: true, configure_account: true }, - reminders: { - view_own: true, - view_all: false, - create: true, - edit_own: true, - edit_all: false, - assign_others: false, - }, - calendar: { connect: true, view_events: true }, - reports: { view_dashboard: true, view_analytics: true, export: true }, - document_templates: { view: true, generate: true, manage: false }, - yachts: { view: true, create: true, edit: true, delete: false, transfer: false }, - companies: { view: true, create: true, edit: false, delete: false }, - memberships: { view: true, manage: false }, - reservations: { view: true, create: true, activate: true, cancel: false }, - admin: { - manage_users: false, - view_audit_log: false, - manage_settings: false, - manage_webhooks: false, - manage_reports: false, - manage_custom_fields: false, - manage_forms: false, - manage_tags: true, - system_backup: false, - permanently_delete_clients: false, - }, - residential_clients: { view: false, create: false, edit: false, delete: false }, - residential_interests: { - view: false, - create: false, - edit: false, - delete: false, - change_stage: false, - }, -}; - -const VIEWER_PERMISSIONS: RolePermissions = { - clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false }, - interests: { - view: true, - create: false, - edit: false, - delete: false, - change_stage: false, - override_stage: false, - generate_eoi: false, - export: false, - }, - berths: { view: true, edit: false, import: false, manage_waiting_list: false }, - documents: { - view: true, - create: false, - edit: false, - send_for_signing: false, - upload_signed: false, - delete: false, - }, - expenses: { - view: true, - create: false, - edit: false, - delete: false, - export: false, - scan_receipt: false, - }, - invoices: { - view: true, - create: false, - edit: false, - delete: false, - send: false, - record_payment: false, - export: false, - }, - files: { view: true, upload: false, edit: false, delete: false, manage_folders: false }, - email: { view: true, send: false, configure_account: false }, - reminders: { - view_own: true, - view_all: false, - create: false, - edit_own: false, - edit_all: false, - assign_others: false, - }, - calendar: { connect: false, view_events: true }, - reports: { view_dashboard: true, view_analytics: false, export: false }, - document_templates: { view: true, generate: false, manage: false }, - yachts: { view: true, create: false, edit: false, delete: false, transfer: false }, - companies: { view: true, create: false, edit: false, delete: false }, - memberships: { view: true, manage: false }, - reservations: { view: true, create: false, activate: false, cancel: false }, - admin: { - manage_users: false, - view_audit_log: false, - manage_settings: false, - manage_webhooks: false, - manage_reports: false, - manage_custom_fields: false, - manage_forms: false, - manage_tags: false, - system_backup: false, - permanently_delete_clients: false, - }, - residential_clients: { view: false, create: false, edit: false, delete: false }, - residential_interests: { - view: false, - create: false, - edit: false, - delete: false, - change_stage: false, - }, -}; - -// Residential Partner — for an outside party who handles residential -// inquiries on the marina's behalf. Sees only the residential pages and -// nothing else; can't see marina clients, yachts, berths, EOIs, etc. -const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = { - clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false }, - interests: { - view: false, - create: false, - edit: false, - delete: false, - change_stage: false, - override_stage: false, - generate_eoi: false, - export: false, - }, - berths: { view: false, edit: false, import: false, manage_waiting_list: false }, - documents: { - view: false, - create: false, - edit: false, - send_for_signing: false, - upload_signed: false, - delete: false, - }, - expenses: { - view: false, - create: false, - edit: false, - delete: false, - export: false, - scan_receipt: false, - }, - invoices: { - view: false, - create: false, - edit: false, - delete: false, - send: false, - record_payment: false, - export: false, - }, - files: { view: false, upload: false, edit: false, delete: false, manage_folders: false }, - email: { view: false, send: false, configure_account: false }, - reminders: { - view_own: true, - view_all: false, - create: true, - edit_own: true, - edit_all: false, - assign_others: false, - }, - calendar: { connect: false, view_events: false }, - reports: { view_dashboard: false, view_analytics: false, export: false }, - document_templates: { view: false, generate: false, manage: false }, - yachts: { view: false, create: false, edit: false, delete: false, transfer: false }, - companies: { view: false, create: false, edit: false, delete: false }, - memberships: { view: false, manage: false }, - reservations: { view: false, create: false, activate: false, cancel: false }, - admin: { - manage_users: false, - view_audit_log: false, - manage_settings: false, - manage_webhooks: false, - manage_reports: false, - manage_custom_fields: false, - manage_forms: false, - manage_tags: false, - system_backup: false, - permanently_delete_clients: false, - }, - residential_clients: { view: true, create: true, edit: true, delete: false }, - residential_interests: { - view: true, - create: true, - edit: true, - delete: false, - change_stage: true, - }, -}; - -// ─── Port Definitions ──────────────────────────────────────────────────────── - -const PORT_DEFINITIONS: Array<{ - name: string; - slug: string; - primaryColor: string; - defaultCurrency: string; - timezone: string; -}> = [ - { - name: 'Port Nimara', - slug: 'port-nimara', - primaryColor: '#0F4C81', - defaultCurrency: 'USD', - timezone: 'America/Anguilla', - }, - // Second port kept for multi-tenant isolation tests (cross-port scoping, - // permission boundaries). Drop or rename if the production install is - // single-port. - { - name: 'Port Amador', - slug: 'port-amador', - primaryColor: '#D97706', - defaultCurrency: 'USD', - timezone: 'America/Panama', - }, -]; - -// ─── Seed Function ──────────────────────────────────────────────────────────── - async function seed() { - console.log('Seeding Port Nimara CRM...'); + console.log('Seeding Port Nimara CRM (realistic fixture)...'); - // ── 1. Ports ──────────────────────────────────────────────────────────────── - console.log('Creating ports...'); - const portIds: Array<{ id: string; name: string; slug: string }> = []; + const portIds = await seedBootstrap(); - for (const def of PORT_DEFINITIONS) { - const [inserted] = await db - .insert(ports) - .values({ - id: crypto.randomUUID(), - name: def.name, - slug: def.slug, - logoUrl: null, - primaryColor: def.primaryColor, - defaultCurrency: def.defaultCurrency, - timezone: def.timezone, - settings: {}, - isActive: true, - }) - .onConflictDoNothing() - .returning(); - - if (inserted) { - console.log(` Port created: ${def.name} (${inserted.id})`); - portIds.push({ id: inserted.id, name: def.name, slug: def.slug }); - } else { - // Port already existed - look it up so we can still seed fixtures for it. - const [existing] = await db.select().from(ports).where(eq(ports.slug, def.slug)).limit(1); - if (existing) { - console.log(` Port exists: ${def.name} (${existing.id})`); - portIds.push({ id: existing.id, name: def.name, slug: def.slug }); - } else { - console.warn(` Port insert conflict but lookup returned no row: ${def.slug}`); - } - } - } - - // ── 2. System Roles ───────────────────────────────────────────────────────── - console.log('Creating system roles...'); - - const systemRoles = [ - { - id: crypto.randomUUID(), - name: 'super_admin', - description: 'Full system access. Bypasses all permission checks.', - permissions: ALL_PERMISSIONS, - isGlobal: true, - isSystem: true, - }, - { - id: crypto.randomUUID(), - name: 'director', - description: 'Operational admin within assigned port(s). Can manage users and settings.', - permissions: DIRECTOR_PERMISSIONS, - isGlobal: true, - isSystem: true, - }, - { - id: crypto.randomUUID(), - name: 'sales_manager', - description: 'Full sales access. Can view all reminders, assign tasks, and export reports.', - permissions: SALES_MANAGER_PERMISSIONS, - isGlobal: true, - isSystem: true, - }, - { - id: crypto.randomUUID(), - name: 'sales_agent', - description: - 'Standard sales role. View/create/edit clients and interests, manage own reminders.', - permissions: SALES_AGENT_PERMISSIONS, - isGlobal: true, - isSystem: true, - }, - { - id: crypto.randomUUID(), - name: 'viewer', - description: 'Read-only access to all records.', - permissions: VIEWER_PERMISSIONS, - isGlobal: true, - isSystem: true, - }, - { - id: crypto.randomUUID(), - name: 'residential_partner', - description: - 'External partner who handles residential inquiries. Sees only the residential pages — no marina clients, yachts, berths, or financial data.', - permissions: RESIDENTIAL_PARTNER_PERMISSIONS, - isGlobal: true, - isSystem: true, - }, - ]; - - for (const role of systemRoles) { - await db.insert(roles).values(role).onConflictDoNothing(); - console.log(` Role: ${role.name}`); - } - - // ── 3. Super Admin User Profile ───────────────────────────────────────────── - // Note: Better Auth creates the actual `user` record on first login. - // We create the profile extension now, linked to a known user_id. - // The Better Auth user_id for matt@portnimara.com must match this value - // once Better Auth is configured. Use a stable placeholder ID here. - console.log('Creating super admin user profile...'); - - const superAdminUserId = 'super-admin-matt-portnimara'; - - await db - .insert(userProfiles) - .values({ - id: crypto.randomUUID(), - userId: superAdminUserId, - displayName: 'Matt', - avatarUrl: null, - phone: null, - isSuperAdmin: true, - isActive: true, - lastLoginAt: null, - preferences: {}, - }) - .onConflictDoNothing(); - - console.log(` Super admin profile for user_id: ${superAdminUserId}`); - - // ── 4. Per-port fixtures ──────────────────────────────────────────────────── console.log(''); console.log('Seeding per-port fixtures...'); @@ -659,7 +31,6 @@ async function seed() { summaries.push({ name: p.name, summary }); } - // ── 5. Summary ───────────────────────────────────────────────────────────── console.log(''); console.log('─── Summary ───────────────────────────────────────────────'); for (const s of summaries) { @@ -674,10 +45,6 @@ async function seed() { } console.log(''); console.log('Seed complete!'); - console.log(''); - console.log('NOTE: The Better Auth user for matt@portnimara.com must be created'); - console.log(`separately. Once created, update user_profiles.user_id to match`); - console.log(`the actual Better Auth user ID (currently placeholder: ${superAdminUserId})`); process.exit(0); }