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) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 20:19:50 +02:00
parent 758d8628cf
commit 4592789712
8 changed files with 1656 additions and 644 deletions

View File

@@ -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<SyntheticSeedSummary | null> {
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<string, string>();
PIPELINE_CLIENTS.forEach((spec, i) => idByTag.set(spec.tag, clientRows[i]!.id));
// Contacts
const contactValues: Array<typeof clientContacts.$inferInsert> = [];
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<PipelineStage, number> = {
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;
});
}