/** * 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, berthTenancies, 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 { /** Stable identifier used by Playwright selectors and intra-seed wiring * (memberships, yachts, etc.). Decoupled from the display name so the * rendered list looks like real client data, not test fixtures. */ 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; /** Sub-status badges for the doc-signing stages (eoi / reservation / contract). * Only meaningful when stage matches; otherwise null/undefined. */ eoiDocStatus?: 'sent' | 'signed'; reservationDocStatus?: 'sent' | 'signed'; contractDocStatus?: 'sent' | 'signed'; /** Index into BERTH_SNAPSHOT for the primary linked berth. */ berthIdx?: number; /** Mark interest as won/lost when stage = contract+signed. */ 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'; /** Acquisition source - varied across the fixture set so the list view * looks like a real funnel rather than a wall of "Manual". */ source?: 'website' | 'manual' | 'referral' | 'broker'; /** How long ago (in days) this client record was created. Spreads the * "Created" column across realistic timestamps so list pages look like * a real CRM with months of history rather than 12 rows all stamped * with today's date. */ createdDaysAgo?: number; } /** * 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. */ /** * Believable demo dataset - names, emails, phone numbers, addresses, and * acquisition sources read like a real marina's prospect list rather * than fixtures keyed on enum names. The `tag` field still carries the * stage/role identity for selectors and intra-seed wiring; nothing in * the rendered UI references it. * * Spread across acquisition sources, ages (3–280 days), and countries * so list / dashboard / kanban surfaces look populated and natural. */ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [ { tag: 'open', fullName: 'Olivia Sinclair', email: 'olivia.sinclair@gmail.com', phone: '+44 7700 900142', countryIso: 'GB', city: 'London', street: '14 Cheyne Walk', postalCode: 'SW3 5RA', stage: 'enquiry', source: 'website', createdDaysAgo: 4, // Open stage: no berth link yet }, { tag: 'details', fullName: 'Daniel Whitaker', email: 'daniel.whitaker@outlook.com', phone: '+1 305 555 0182', countryIso: 'US', city: 'Miami', street: '880 Brickell Bay Drive', postalCode: '33131', stage: 'enquiry', berthIdx: 0, source: 'broker', createdDaysAgo: 12, }, { tag: 'comms', fullName: 'Carla Mendoza', email: 'carla.mendoza@gmail.com', phone: '+34 971 555 028', countryIso: 'ES', city: 'Palma de Mallorca', street: 'Carrer de Sant Magí 23', postalCode: '07013', stage: 'qualified', berthIdx: 5, source: 'referral', createdDaysAgo: 28, }, { tag: 'eoi-sent', fullName: 'Marco Bianchi', email: 'marco.bianchi@libero.it', phone: '+39 010 8740 215', countryIso: 'IT', city: 'Genoa', street: 'Via XX Settembre 47', postalCode: '16121', stage: 'eoi', eoiDocStatus: 'sent', berthIdx: 6, source: 'broker', createdDaysAgo: 45, }, { tag: 'eoi-signed', fullName: 'Sara Laurent', email: 'sara.laurent@orange.fr', phone: '+33 4 93 92 18 47', countryIso: 'FR', city: 'Nice', street: '8 Promenade des Anglais', postalCode: '06000', stage: 'eoi', eoiDocStatus: 'signed', berthIdx: 7, source: 'website', createdDaysAgo: 72, }, { tag: 'deposit', fullName: 'Nikolas Papadakis', email: 'n.papadakis@gmail.com', phone: '+30 210 8945 612', countryIso: 'GR', city: 'Athens', street: 'Vouliagmenis Avenue 142', postalCode: '16674', stage: 'deposit_paid', berthIdx: 8, source: 'referral', createdDaysAgo: 95, }, { tag: 'contract-sent', fullName: 'Connor Murphy', email: 'connor.murphy@me.com', phone: '+353 1 555 0184', countryIso: 'IE', city: 'Dublin', street: '12 Merrion Square North', postalCode: 'D02 E2X3', stage: 'contract', contractDocStatus: 'sent', berthIdx: 9, source: 'manual', createdDaysAgo: 118, }, { tag: 'contract-signed', fullName: 'Carmen Costa', email: 'carmen.costa@sapo.pt', phone: '+351 21 386 4520', countryIso: 'PT', city: 'Lisbon', street: 'Rua Garrett 88', postalCode: '1200-205', stage: 'contract', contractDocStatus: 'signed', berthIdx: 4, source: 'broker', createdDaysAgo: 156, }, { tag: 'completed-won', fullName: 'Carlos Vega', email: 'carlos.vega@gmail.com', phone: '+507 6612 4485', countryIso: 'PA', city: 'Panama City', street: 'Calle 50, Torre Banistmo Piso 18', postalCode: '0816', stage: 'contract', contractDocStatus: 'signed', berthIdx: 10, outcome: 'won', source: 'referral', createdDaysAgo: 245, }, { tag: 'completed-lost', fullName: 'Hannah Schmidt', email: 'hannah.schmidt@gmx.de', phone: '+49 40 4286 9152', countryIso: 'DE', city: 'Hamburg', street: 'Alsterufer 28', postalCode: '20354', stage: 'enquiry', berthIdx: 1, outcome: 'lost_unqualified', source: 'website', createdDaysAgo: 84, }, { tag: 'archived-simple', fullName: 'Anna de Jong', email: 'anna.dejong@kpn.nl', phone: '+31 20 624 7185', countryIso: 'NL', city: 'Amsterdam', street: 'Herengracht 412', postalCode: '1017 BX', archive: 'simple', source: 'website', createdDaysAgo: 201, }, { tag: 'archived-rich', fullName: 'Rita Vermeulen', email: 'rita.vermeulen@telenet.be', phone: '+32 3 226 8420', countryIso: 'BE', city: 'Antwerp', street: 'Meir 102', postalCode: '2000', archive: 'rich', source: 'broker', createdDaysAgo: 280, }, ]; 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) => { const created = spec.createdDaysAgo !== undefined ? daysAgo(spec.createdDaysAgo) : new Date(); return { portId, fullName: spec.fullName, nationalityIso: spec.countryIso, preferredContactMethod: 'email' as const, preferredLanguage: 'en', source: spec.source ?? ('manual' as const), // Override the schema default so the list page shows a // realistic range of "Created" timestamps rather than 12 // rows all stamped with today's date. updated_at gets the // same value so sorted-by-recency lists put the freshest // records first. createdAt: created, updatedAt: created, }; }), ) .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. // Sold = contract+signed+won. Under offer = active berth-bearing stages // (eoi / reservation / deposit_paid / contract-not-yet-won). const stageToBerthStatus = ( spec: SyntheticClientSpec, ): 'available' | 'under_offer' | 'sold' | null => { const stage = spec.stage; if (!stage) return null; if (stage === 'contract' && spec.contractDocStatus === 'signed' && spec.outcome === 'won') { return 'sold'; } if ( stage === 'eoi' || stage === 'reservation' || stage === 'deposit_paid' || stage === 'contract' ) { return 'under_offer'; } return null; }; for (const spec of PIPELINE_CLIENTS) { if (spec.berthIdx === undefined) continue; const newStatus = stageToBerthStatus(spec); 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)!; // Derive deal age from the (stage, doc-sub-status) pair so a // contract+signed+won record looks older than a brand-new enquiry. const stageDaysAgoMap: Record = { enquiry: 5, qualified: 10, nurturing: 30, eoi: spec.eoiDocStatus === 'signed' ? 35 : 20, reservation: 50, deposit_paid: 60, contract: spec.outcome === 'won' ? 200 : spec.contractDocStatus === 'signed' ? 110 : 80, }; const ageDays = stageDaysAgoMap[spec.stage]; const yachtId = spec.tag === 'completed-won' ? charterYachtRow[0]!.id : null; // Stage-progression flags so the date_* timestamps cascade correctly. // Anything past "eoi+sent" implies the EOI was at least sent. const eoiReached = spec.stage === 'eoi' || spec.stage === 'reservation' || spec.stage === 'deposit_paid' || spec.stage === 'contract'; const eoiSigned = (spec.stage === 'eoi' && spec.eoiDocStatus === 'signed') || spec.stage === 'reservation' || spec.stage === 'deposit_paid' || spec.stage === 'contract'; const [intRow] = await tx .insert(interests) .values({ portId, clientId, yachtId, pipelineStage: spec.stage, eoiDocStatus: spec.eoiDocStatus ?? (eoiSigned ? 'signed' : null), reservationDocStatus: spec.reservationDocStatus ?? null, contractDocStatus: spec.contractDocStatus ?? null, leadCategory: spec.stage === 'enquiry' ? 'general_interest' : spec.stage === 'qualified' || spec.stage === 'nurturing' ? 'specific_qualified' : 'hot_lead', source: 'manual' as const, dateFirstContact: daysAgo(ageDays), dateLastContact: daysAgo(Math.max(0, ageDays - 2)), dateEoiSent: eoiReached ? daysAgo(Math.max(0, ageDays - 5)) : null, dateEoiSigned: eoiSigned ? daysAgo(Math.max(0, ageDays - 10)) : null, eoiStatus: spec.stage === 'eoi' && spec.eoiDocStatus === 'sent' ? 'waiting_for_signatures' : eoiSigned ? '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 !== 'enquiry' && spec.stage !== 'qualified', 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: 'enquiry', 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. // berthTenancies 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(berthTenancies).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; }); }