/** * 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; }); }