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>
829 lines
27 KiB
TypeScript
829 lines
27 KiB
TypeScript
/**
|
||
* 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<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;
|
||
});
|
||
}
|