Files
pn-new-crm/src/lib/db/seed-synthetic-data.ts
Matt ccc775dc66 feat(tenancies-p2): rename berth_reservations → berth_tenancies (schema + perms + UI)
73-file atomic rename per docs/tenancies-design.md:

- Migration 0085: rename table + indexes + FK constraints; rename
  documents.reservation_id → tenancy_id; migrate jsonb permission maps
  (reservations resource → tenancies; collapse create+activate → manage);
  rewrite historical audit_logs.entity_type='berth_reservation' →
  'berth_tenancy'. FK renames wrapped in DO blocks so dev DBs that pre-date
  the FK additions don't abort.
- Schema: berthReservations → berthTenancies; BerthReservation type →
  BerthTenancy; indexes idx_br_* / idx_brr_* → idx_bt_*.
- RolePermissions: resource { view, create, activate, cancel } collapses to
  { view, manage, cancel }; all 8 default seed bundles + role-form + matrix
  updated.
- Service: berth-reservations.service.ts → berth-tenancies.service.ts;
  endReservation → endTenancy; listReservations → listTenancies.
- API: /api/v1/berth-reservations → /api/v1/tenancies (+ nested [id]);
  /api/v1/berths/[id]/reservations → /api/v1/berths/[id]/tenancies.
- Validators: reservations.ts → tenancies.ts; RESERVATION_STATUSES →
  TENANCY_STATUSES; endReservationSchema → endTenancySchema.
- Routes: /{portSlug}/berth-reservations → /{portSlug}/tenancies;
  /portal/my-reservations → /portal/my-tenancies.
- Components: src/components/reservations/* → src/components/tenancies/*;
  BerthReservationsTab → BerthTenanciesTab; ClientReservationsTab →
  ClientTenanciesTab; ReservationList → TenancyList.
- Socket events: berth_reservation:* → berth_tenancy:*; payload
  reservationId → tenancyId.
- Webhook events: berth_reservation.* → berth_tenancy.*.
- Portal: getPortalUserReservations → getPortalUserTenancies;
  PortalReservation → PortalTenancy; PortalDashboard.counts.activeReservations
  → activeTenancies; PortalNav label "Reservations" → "Tenancies".
- Dossier: DossierReservation → DossierTenancy; reservationDecisions →
  tenancyDecisions across smart-archive-dialog + bulk-archive routes.
- Documents schema: documents.reservationId → documents.tenancyId
  (TS + DB column + index + FK constraint).
- Activity feed label berth_reservation → berth_tenancy (matched against
  migrated historical audit rows).

KEPT (separate concepts):
- Reservation Agreement document type (the contract sent to clients).
- "Reservation" pipeline stage name.
- {{reservation.*}} merge tokens in template authoring.
- interest.reservationStatus / reservationDocStatus / dateReservationSent
  fields (track agreement signing on the deal).
- reservation-agreement-context.ts service (builds merge context for the
  Reservation Agreement doc; only its DB imports were renamed).

Verified: tsc clean, 1480/1480 vitest passing, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:09:35 +02:00

829 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (3280 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<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) => {
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<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.
// 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<PipelineStage, number> = {
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;
});
}