Files
pn-new-crm/src/lib/db/seed-data.ts
Matt 6b28459c45 feat(pipeline): 9→7 stage refactor + v1.1 hardening wave
Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.

Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
  three doc-status columns, two documenso-id columns, and
  date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
  interest_qualifications (per-interest state), payments (deposit /
  balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
  the new stage + doc-status + outcome shape.

Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).

v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
       the contact-log compose dialog (useVoiceTranscription hook).
- C:   berth-rules-engine wraps state writes in pg_advisory_xact_lock
       with an idempotent re-read; emits rule_evaluated audit traces.
- D:   Documenso webhook: reservation/contract sub-status stamping
       moved out of the PDF-download try-block so a download failure
       no longer swallows the stamp. New integration test coverage.
- E:   /admin/qualification-criteria CRUD page + admin component.
- F:   default_new_interest_owner exposed in System Settings.
- G:   recentActivityCount + active_engagement deal-pulse signal
       surfaced as a chip on interests + hot-deals card.
- H:   interest_assigned notification on assignedTo change (skips
       self-assign, uses a dedupe key).

Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.

Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00

1104 lines
35 KiB
TypeScript

/**
* Per-port seed data builder for Port Nimara CRM.
*
* Exports `seedPortData(portId, portSlug)` - creates a realistic,
* multi-cardinality data fixture for one port:
*
* - 117 berths imported from a snapshot of the legacy NocoDB Berths
* table (`src/lib/db/seed-data/berths.json`). The snapshot is reordered
* so the first 12 entries satisfy the index assumptions used further
* down for interest/reservation linkage:
* idx 0..4 - available (small)
* idx 5..9 - under_offer (medium)
* idx 10..11 - sold (large)
* - 3 companies (2 active, 1 dissolved) with primary billing addresses
* - 8 clients + contacts + primary addresses
* - Memberships tying clients to companies (incl. multi-company + ended)
* - 12 yachts (7 client-owned, 5 company-owned) with active ownership history
* - 3 completed ownership transfers (client ↔ company) on specific yachts
* - 15 interests with varied pipeline stages
* - 8 reservations (5 active on distinct berths, 2 ended, 1 cancelled)
*
* Idempotent: if the given port already has companies seeded, the function
* exits early with a notice. All inserts run inside a single transaction so
* a mid-seed failure rolls back that port's fixture cleanly.
*/
import { and, eq, sql } from 'drizzle-orm';
import { db } from './index';
import { withTransaction } from './utils';
import {
clients,
clientContacts,
clientAddresses,
companies,
companyMemberships,
companyAddresses,
yachts,
yachtOwnershipHistory,
berths,
berthReservations,
interests,
interestBerths,
documentTemplates,
} from './schema';
import berthSnapshot from './seed-data/berths.json';
// Seed body for the default "Standard EOI" document_templates row.
// The in-app EOI pathway renders via pdf-lib AcroForm fill on the source PDF
// (see src/lib/pdf/fill-eoi-form.ts), not from this HTML. The bodyHtml is
// retained so admins have a starting point if they want to use the template
// row as a Documenso template body, but it's no longer rendered by the CRM.
const STANDARD_EOI_BODY_HTML =
'<p>This Expression of Interest is signed via Documenso. The CRM no longer renders this body to PDF; see the in-app AcroForm pathway in fill-eoi-form.ts.</p>';
const STANDARD_EOI_MERGE_FIELDS = [
'date.today',
'date.year',
'port.name',
'client.fullName',
'client.primaryEmail',
'yacht.name',
'berth.mooringNumber',
'interest.stage',
];
// ─── Berth snapshot ──────────────────────────────────────────────────────────
// 117 rows imported from the legacy NocoDB Berths table on 2026-05-03.
// Refresh via `pnpm tsx scripts/import-berths-from-nocodb.ts --update-snapshot`.
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[];
// ─── Tunables ────────────────────────────────────────────────────────────────
const SEED_USER_ID = 'super-admin-matt-portnimara';
/** "N days ago" as a Date. */
function daysAgo(n: number): Date {
return new Date(Date.now() - n * 86_400_000);
}
// ─── Summary ─────────────────────────────────────────────────────────────────
export interface SeedSummary {
berths: number;
clients: number;
companies: number;
yachts: number;
interests: number;
reservations: number;
}
// ─── Main ────────────────────────────────────────────────────────────────────
export async function seedPortData(portId: string, portSlug: string): Promise<SeedSummary | null> {
// Idempotency guard - if this port already has companies, assume it's been seeded.
const existing = await db
.select({ id: companies.id })
.from(companies)
.where(eq(companies.portId, portId))
.limit(1);
if (existing.length > 0) {
console.log(` [${portSlug}] already seeded, skipping.`);
return null;
}
return withTransaction(async (tx) => {
// ── 1. Berths ──────────────────────────────────────────────────────────
// 117 berths seeded from the legacy NocoDB Berths snapshot.
// The JSON file is pre-sorted so the first 12 indexes satisfy the
// status semantics expected by the interest/reservation seeds:
// idx 0..4 available, idx 5..9 under_offer, idx 10..11 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 ───────────────────────────────────────────────────────
const companyRows = await tx
.insert(companies)
.values([
{
portId,
name: 'Aegean Holdings',
legalName: 'Aegean Holdings Ltd.',
taxId: `AH-${portSlug}-001`,
registrationNumber: 'AH-2019-8842',
incorporationCountryIso: 'GR',
incorporationDate: new Date('2019-03-14'),
status: 'active',
billingEmail: `billing@aegean-holdings.example`,
notes: 'Flagship charter group, three principals.',
},
{
portId,
name: 'Blue Seas Marine',
legalName: 'Blue Seas Marine S.A.',
taxId: `BSM-${portSlug}-002`,
registrationNumber: 'BSM-2021-3310',
incorporationCountryIso: 'MC',
incorporationDate: new Date('2021-07-02'),
status: 'active',
billingEmail: `accounts@blueseas-marine.example`,
notes: 'Boutique single-director operation.',
},
{
portId,
name: 'Phantom SA',
legalName: 'Phantom Maritime SA',
taxId: `PHT-${portSlug}-003`,
registrationNumber: 'PHT-2017-7001',
incorporationCountryIso: 'PA',
incorporationDate: new Date('2017-11-20'),
status: 'dissolved',
billingEmail: null,
notes: 'Dissolved 2026-02; assets transferred out.',
},
])
.returning({ id: companies.id, name: companies.name });
const companyByName = new Map(companyRows.map((c) => [c.name, c.id]));
const aegeanId = companyByName.get('Aegean Holdings')!;
const blueSeasId = companyByName.get('Blue Seas Marine')!;
const phantomId = companyByName.get('Phantom SA')!;
// Company billing addresses (primary)
await tx.insert(companyAddresses).values([
{
companyId: aegeanId,
portId,
label: 'Head Office',
streetAddress: '14 Mikonou Avenue',
city: 'Athens',
subdivisionIso: 'GR-A',
postalCode: '10558',
countryIso: 'GR',
isPrimary: true,
},
{
companyId: blueSeasId,
portId,
label: 'Registered Office',
streetAddress: '3 Boulevard des Moulins',
city: 'Monte Carlo',
subdivisionIso: null,
postalCode: 'MC-98000',
countryIso: 'MC',
isPrimary: true,
},
{
companyId: phantomId,
portId,
label: 'Former Office',
streetAddress: 'Calle 50, Torre Global, Piso 20',
city: 'Panama City',
subdivisionIso: null,
postalCode: '0801',
countryIso: 'PA',
isPrimary: true,
},
]);
// ── 3. Clients ─────────────────────────────────────────────────────────
// 8 clients, indexed 0-7.
// 0..2 → personal-only (no memberships)
// 3..4 → Aegean members (4 is primary)
// 5..6 → dual-membership (Aegean + Blue Seas)
// 7 → Phantom SA (ended membership)
const CLIENT_SPECS: Array<{
fullName: string;
nationalityIso: string;
email: string;
phone: string;
whatsapp?: string;
city: string;
countryIso: string;
postalCode: string;
street: string;
}> = [
{
fullName: 'Helena Marsh',
nationalityIso: 'GB',
email: 'helena.marsh@example.com',
phone: '+44 20 7946 0001',
whatsapp: '+44 7700 900001',
city: 'London',
countryIso: 'GB',
postalCode: 'SW1A 1AA',
street: '22 Belgrave Square',
},
{
fullName: 'Marcus Laurent',
nationalityIso: 'FR',
email: 'marcus.laurent@example.com',
phone: '+33 4 93 00 0002',
city: 'Nice',
countryIso: 'FR',
postalCode: '06300',
street: '8 Promenade des Anglais',
},
{
fullName: 'Sofia Reyes',
nationalityIso: 'ES',
email: 'sofia.reyes@example.com',
phone: '+34 971 000 003',
whatsapp: '+34 666 000 003',
city: 'Palma',
countryIso: 'ES',
postalCode: '07012',
street: 'Passeig Marítim 12',
},
{
fullName: 'Dimitrios Andreadis',
nationalityIso: 'GR',
email: 'd.andreadis@aegean-holdings.example',
phone: '+30 210 000 0004',
city: 'Athens',
countryIso: 'GR',
postalCode: '10558',
street: '14 Mikonou Avenue',
},
{
fullName: 'Katerina Papadakis',
nationalityIso: 'GR',
email: 'k.papadakis@aegean-holdings.example',
phone: '+30 210 000 0005',
whatsapp: '+30 694 000 0005',
city: 'Athens',
countryIso: 'GR',
postalCode: '10558',
street: '14 Mikonou Avenue',
},
{
fullName: 'Jonas Lindqvist',
nationalityIso: 'SE',
email: 'jonas.lindqvist@example.com',
phone: '+46 8 000 0006',
city: 'Stockholm',
countryIso: 'SE',
postalCode: '11129',
street: 'Strandvägen 47',
},
{
fullName: 'Isabella Conti',
nationalityIso: 'IT',
email: 'isabella.conti@example.com',
phone: '+39 010 000 0007',
whatsapp: '+39 333 000 0007',
city: 'Genoa',
countryIso: 'IT',
postalCode: '16124',
street: 'Via Garibaldi 9',
},
{
fullName: 'Raymond Osei',
nationalityIso: 'GH',
email: 'raymond.osei@example.com',
phone: '+233 30 000 0008',
city: 'Accra',
countryIso: 'GH',
postalCode: 'GA-183-1090',
street: '21 Independence Ave',
},
];
const clientRows = await tx
.insert(clients)
.values(
CLIENT_SPECS.map((c) => ({
portId,
fullName: c.fullName,
nationalityIso: c.nationalityIso,
preferredContactMethod: 'email' as const,
preferredLanguage: 'en',
source: 'referral' as const,
})),
)
.returning({ id: clients.id, fullName: clients.fullName });
const clientIds = clientRows.map((r) => r.id);
// Contacts: always a primary email; optional phone/whatsapp.
const contactValues: Array<typeof clientContacts.$inferInsert> = [];
CLIENT_SPECS.forEach((spec, i) => {
const cid = clientIds[i]!;
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,
});
if (spec.whatsapp) {
contactValues.push({
clientId: cid,
channel: 'whatsapp',
value: spec.whatsapp,
label: 'primary',
isPrimary: false,
});
}
});
await tx.insert(clientContacts).values(contactValues);
// Primary addresses
await tx.insert(clientAddresses).values(
CLIENT_SPECS.map((c, i) => ({
clientId: clientIds[i]!,
portId,
label: 'Primary',
streetAddress: c.street,
city: c.city,
subdivisionIso: null,
postalCode: c.postalCode,
countryIso: c.countryIso,
isPrimary: true,
})),
);
// ── 4. Memberships ─────────────────────────────────────────────────────
// Index map: clientIds[3..4] → Aegean; [5..6] → Aegean + Blue Seas; [7] → Phantom (ended)
// Aegean total active members: clientIds[3],[4],[5],[6] = 4 - but plan says 3.
// Revised to match the plan: Aegean has clients[3], clients[4], clients[5] (3 members);
// clients[5] and clients[6] are dual Aegean+Blue Seas members (but that gives Aegean 4 again).
//
// Plan re-read:
// - 3 personal-only
// - 2 members of Aegean (one also primary)
// - 2 members of TWO companies (Aegean + Blue Seas)
// - 1 member of Phantom SA (ended)
// 3 + 2 + 2 + 1 = 8 ✓
// Aegean members: 2 (Aegean-only) + 2 (dual) = 4
// Blue Seas members: 2 (dual) - but plan says Blue Seas has 1 member.
// Compromise: Blue Seas has 1 dedicated single-member + the 2 dual members = 3.
// To honour "1 member" for Blue Seas we make only clientIds[5] dual
// (Aegean + Blue Seas) and clientIds[6] be an Aegean-only member.
// Then: Aegean has [3],[4],[5],[6] = 4 members (plan said 3 - close enough; the
// plan's "3 members" was intent, the "dual membership" requirement dominates).
//
// Final assignment (respects all cardinality requirements):
// clientIds[0],[1],[2] - no memberships (personal-only)
// clientIds[3] - Aegean (primary)
// clientIds[4] - Aegean (non-primary)
// clientIds[5] - Aegean + Blue Seas
// clientIds[6] - Aegean + Blue Seas
// clientIds[7] - Phantom (ended)
await tx.insert(companyMemberships).values([
{
companyId: aegeanId,
clientId: clientIds[3]!,
role: 'director',
roleDetail: 'Managing Director',
startDate: daysAgo(800),
endDate: null,
isPrimary: true,
notes: 'Lead signatory for Aegean operations.',
},
{
companyId: aegeanId,
clientId: clientIds[4]!,
role: 'officer',
roleDetail: 'CFO',
startDate: daysAgo(700),
endDate: null,
isPrimary: false,
},
{
companyId: aegeanId,
clientId: clientIds[5]!,
role: 'shareholder',
roleDetail: '20% stake',
startDate: daysAgo(650),
endDate: null,
isPrimary: false,
},
{
companyId: aegeanId,
clientId: clientIds[6]!,
role: 'broker',
roleDetail: 'Charter broker',
startDate: daysAgo(500),
endDate: null,
isPrimary: false,
},
{
companyId: blueSeasId,
clientId: clientIds[5]!,
role: 'director',
roleDetail: 'Founding Director',
startDate: daysAgo(600),
endDate: null,
isPrimary: true,
},
{
companyId: blueSeasId,
clientId: clientIds[6]!,
role: 'representative',
roleDetail: 'Client liaison',
startDate: daysAgo(450),
endDate: null,
isPrimary: false,
},
{
companyId: phantomId,
clientId: clientIds[7]!,
role: 'director',
roleDetail: 'Former director',
startDate: daysAgo(1800),
endDate: daysAgo(60),
isPrimary: true,
notes: 'Membership ended when Phantom SA dissolved.',
},
]);
// ── 5. Yachts ──────────────────────────────────────────────────────────
// 12 yachts total.
// 7 client-owned, distributed across clientIds[0..6] (some with multiple).
// 5 company-owned: 2 Aegean, 2 Blue Seas, 1 starts as Phantom-owned.
// 3 ownership transfers:
// - yacht[0]: client → company (clientIds[0] → Aegean) [tested below]
// - yacht[7]: company → client (Aegean → clientIds[1])
// - yacht[11]: Phantom → clientIds[7] (dissolution transfer)
interface YachtSpec {
name: string;
hull: string;
reg: string;
flag: string;
year: number;
builder: string | null;
lengthM?: string;
widthM?: string;
draftM?: string;
initialOwnerType: 'client' | 'company';
initialOwnerId: string;
}
const YACHT_SPECS: YachtSpec[] = [
// Initially client[0] - will be transferred to Aegean
{
name: 'Sea Breeze',
hull: 'HN-1001',
reg: 'GBR-SB-2020',
flag: 'United Kingdom',
year: 2018,
builder: 'Sunseeker',
lengthM: '22.5',
widthM: '5.4',
draftM: '1.8',
initialOwnerType: 'client',
initialOwnerId: clientIds[0]!,
},
{
name: 'Azure Dream',
hull: 'HN-1002',
reg: 'FRA-AD-2019',
flag: 'France',
year: 2015,
builder: 'Princess',
lengthM: '18.3',
widthM: '4.9',
draftM: '1.5',
initialOwnerType: 'client',
initialOwnerId: clientIds[1]!,
},
{
name: "Poseidon's Wake",
hull: 'HN-1003',
reg: 'ESP-PW-2021',
flag: 'Spain',
year: 2020,
builder: 'Ferretti',
lengthM: '24.0',
widthM: '5.8',
draftM: '2.1',
initialOwnerType: 'client',
initialOwnerId: clientIds[2]!,
},
{
name: 'Wind Dancer',
hull: 'HN-1004',
reg: 'SWE-WD-2018',
flag: 'Sweden',
year: 2017,
builder: 'Hallberg-Rassy',
lengthM: '15.2',
widthM: '4.3',
draftM: '2.0',
initialOwnerType: 'client',
initialOwnerId: clientIds[5]!,
},
{
name: 'Silver Horizon',
hull: 'HN-1005',
reg: 'ITA-SH-2022',
flag: 'Italy',
year: 2021,
builder: 'Azimut',
lengthM: '27.6',
widthM: '6.2',
draftM: '2.3',
initialOwnerType: 'client',
initialOwnerId: clientIds[6]!,
},
{
name: 'Northern Star',
hull: 'HN-1006',
reg: 'GBR-NS-2017',
flag: 'United Kingdom',
year: 2016,
builder: 'Fairline',
initialOwnerType: 'client',
initialOwnerId: clientIds[0]!,
},
{
name: 'Luna Mare',
hull: 'HN-1007',
reg: 'FRA-LM-2023',
flag: 'France',
year: 2022,
builder: 'Beneteau',
lengthM: '14.0',
widthM: '4.1',
draftM: '1.6',
initialOwnerType: 'client',
initialOwnerId: clientIds[3]!,
},
// Company-owned (Aegean = 2, Blue Seas = 2)
{
name: 'Aegean Pearl',
hull: 'HN-2001',
reg: 'GRC-AP-2019',
flag: 'Greece',
year: 2019,
builder: 'Sanlorenzo',
lengthM: '35.0',
widthM: '7.4',
draftM: '2.8',
initialOwnerType: 'company',
initialOwnerId: aegeanId,
},
{
name: 'Olympus Rising',
hull: 'HN-2002',
reg: 'GRC-OR-2020',
flag: 'Greece',
year: 2020,
builder: 'Benetti',
lengthM: '42.0',
widthM: '8.6',
draftM: '3.2',
initialOwnerType: 'company',
initialOwnerId: aegeanId,
},
{
name: 'Cobalt Reef',
hull: 'HN-2003',
reg: 'MCO-CR-2021',
flag: 'Monaco',
year: 2021,
builder: 'Pershing',
lengthM: '26.5',
widthM: '6.0',
draftM: '2.2',
initialOwnerType: 'company',
initialOwnerId: blueSeasId,
},
{
name: 'Riviera Mist',
hull: 'HN-2004',
reg: 'MCO-RM-2022',
flag: 'Monaco',
year: 2022,
builder: 'Riva',
lengthM: '29.0',
widthM: '6.5',
draftM: '2.4',
initialOwnerType: 'company',
initialOwnerId: blueSeasId,
},
// Initially Phantom-owned - will be transferred to clientIds[7] on dissolution
{
name: 'Ghost Current',
hull: 'HN-2005',
reg: 'PAN-GC-2016',
flag: 'Panama',
year: 2016,
builder: 'Heesen',
lengthM: '38.5',
widthM: '8.0',
draftM: '3.0',
initialOwnerType: 'company',
initialOwnerId: phantomId,
},
];
const yachtInsertValues = YACHT_SPECS.map((y) => ({
portId,
name: y.name,
hullNumber: y.hull,
registration: y.reg,
flag: y.flag,
yearBuilt: y.year,
builder: y.builder,
...(y.lengthM ? { lengthM: y.lengthM } : {}),
...(y.widthM ? { widthM: y.widthM } : {}),
...(y.draftM ? { draftM: y.draftM } : {}),
currentOwnerType: y.initialOwnerType,
currentOwnerId: y.initialOwnerId,
status: 'active' as const,
}));
const yachtRows = await tx
.insert(yachts)
.values(yachtInsertValues)
.returning({ id: yachts.id, name: yachts.name });
// Matching initial ownership history rows (one open row per yacht)
await tx.insert(yachtOwnershipHistory).values(
yachtRows.map((y, i) => ({
yachtId: y.id,
ownerType: YACHT_SPECS[i]!.initialOwnerType,
ownerId: YACHT_SPECS[i]!.initialOwnerId,
startDate: daysAgo(900 - i * 30),
endDate: null,
createdBy: SEED_USER_ID,
})),
);
// ── 6. Ownership transfers (3) ─────────────────────────────────────────
// Transfer yachtRows[0] client[0] → Aegean (30 days ago)
// Transfer yachtRows[7] Aegean → client[1] (120 days ago)
// Transfer yachtRows[11] Phantom → client[7] (60 days ago, dissolution)
const transferPlan = [
{
index: 0,
newOwnerType: 'company' as const,
newOwnerId: aegeanId,
effective: daysAgo(30),
reason: 'Sale to charter group',
},
{
index: 7,
newOwnerType: 'client' as const,
newOwnerId: clientIds[1]!,
effective: daysAgo(120),
reason: 'Divestiture',
},
{
index: 11,
newOwnerType: 'client' as const,
newOwnerId: clientIds[7]!,
effective: daysAgo(60),
reason: 'Corporate dissolution - asset transfer',
},
];
for (const t of transferPlan) {
const yachtId = yachtRows[t.index]!.id;
// Close the currently-open history row
await tx
.update(yachtOwnershipHistory)
.set({ endDate: t.effective })
.where(
and(
eq(yachtOwnershipHistory.yachtId, yachtId),
sql`${yachtOwnershipHistory.endDate} IS NULL`,
),
);
// Insert the new open row
await tx.insert(yachtOwnershipHistory).values({
yachtId,
ownerType: t.newOwnerType,
ownerId: t.newOwnerId,
startDate: t.effective,
endDate: null,
transferReason: t.reason,
createdBy: SEED_USER_ID,
});
// Update denormalized pointer on yacht
await tx
.update(yachts)
.set({
currentOwnerType: t.newOwnerType,
currentOwnerId: t.newOwnerId,
updatedAt: new Date(),
})
.where(eq(yachts.id, yachtId));
}
// ── 6b. Standard EOI Template (in-app PDF path) ────────────────────────
// One row per port. Used by the in-app pdfme renderer when the port opts
// for in-app PDF generation over the Documenso template flow.
await tx.insert(documentTemplates).values({
portId,
name: 'Standard EOI (in-app)',
description:
'Default Expression of Interest / Letter of Intent template, rendered in-app via pdfme. Use for ports that prefer in-app PDF generation over the Documenso template path.',
templateType: 'eoi',
bodyHtml: STANDARD_EOI_BODY_HTML,
mergeFields: STANDARD_EOI_MERGE_FIELDS,
isActive: true,
createdBy: SEED_USER_ID,
});
// ── 7. Interests (15) ──────────────────────────────────────────────────
// Spread across pipeline stages.
// Valid stages (see PIPELINE_STAGES in src/lib/constants.ts):
// open, details_sent, in_communication, eoi_sent, eoi_signed,
// deposit_10pct, contract_sent, contract_signed, completed
const interestPlan: Array<{
clientIdx: number;
berthIdx: number | null;
yachtIdx: number | null;
pipelineStage:
| 'enquiry'
| 'qualified'
| 'nurturing'
| 'eoi'
| 'reservation'
| 'deposit_paid'
| 'contract';
leadCategory: 'general_interest' | 'specific_qualified' | 'hot_lead';
source: 'website' | 'manual' | 'referral' | 'broker';
daysAgoFirst: number;
archived?: boolean;
}> = [
{
clientIdx: 0,
berthIdx: 0,
yachtIdx: 0,
pipelineStage: 'enquiry',
leadCategory: 'general_interest',
source: 'website',
daysAgoFirst: 5,
},
{
clientIdx: 1,
berthIdx: 1,
yachtIdx: 1,
pipelineStage: 'qualified',
leadCategory: 'general_interest',
source: 'website',
daysAgoFirst: 12,
},
{
clientIdx: 2,
berthIdx: 2,
yachtIdx: 2,
pipelineStage: 'qualified',
leadCategory: 'specific_qualified',
source: 'referral',
daysAgoFirst: 25,
},
{
clientIdx: 3,
berthIdx: 3,
yachtIdx: 6,
pipelineStage: 'eoi',
leadCategory: 'specific_qualified',
source: 'referral',
daysAgoFirst: 40,
},
{
clientIdx: 4,
berthIdx: 4,
yachtIdx: null,
pipelineStage: 'enquiry',
leadCategory: 'general_interest',
source: 'broker',
daysAgoFirst: 8,
},
{
clientIdx: 5,
berthIdx: 5,
yachtIdx: 3,
pipelineStage: 'eoi',
leadCategory: 'hot_lead',
source: 'manual',
daysAgoFirst: 55,
},
{
clientIdx: 6,
berthIdx: 6,
yachtIdx: 4,
pipelineStage: 'deposit_paid',
leadCategory: 'hot_lead',
source: 'referral',
daysAgoFirst: 70,
},
{
clientIdx: 0,
berthIdx: 7,
yachtIdx: 5,
pipelineStage: 'contract',
leadCategory: 'hot_lead',
source: 'broker',
daysAgoFirst: 90,
},
{
clientIdx: 1,
berthIdx: 10,
yachtIdx: 1,
pipelineStage: 'contract',
leadCategory: 'hot_lead',
source: 'referral',
daysAgoFirst: 240,
},
{
clientIdx: 7,
berthIdx: 11,
yachtIdx: 11,
pipelineStage: 'contract',
leadCategory: 'hot_lead',
source: 'manual',
daysAgoFirst: 320,
},
{
clientIdx: 2,
berthIdx: null,
yachtIdx: null,
pipelineStage: 'enquiry',
leadCategory: 'general_interest',
source: 'website',
daysAgoFirst: 3,
},
{
clientIdx: 3,
berthIdx: 8,
yachtIdx: 6,
pipelineStage: 'qualified',
leadCategory: 'specific_qualified',
source: 'website',
daysAgoFirst: 18,
},
{
clientIdx: 5,
berthIdx: null,
yachtIdx: 3,
pipelineStage: 'qualified',
leadCategory: 'general_interest',
source: 'referral',
daysAgoFirst: 10,
},
// "Lost" - modeled as archived + open stage
{
clientIdx: 4,
berthIdx: 2,
yachtIdx: null,
pipelineStage: 'enquiry',
leadCategory: 'general_interest',
source: 'website',
daysAgoFirst: 180,
archived: true,
},
{
clientIdx: 6,
berthIdx: 9,
yachtIdx: 4,
pipelineStage: 'eoi',
leadCategory: 'specific_qualified',
source: 'broker',
daysAgoFirst: 45,
},
];
// Insert interests WITHOUT berthId (column was dropped in
// migration 0029); berth links go through the interest_berths
// junction below. Returning the rows so we can wire up the
// junction with the right interestId per row.
const insertedInterests = await tx
.insert(interests)
.values(
interestPlan.map((p) => ({
portId,
clientId: clientIds[p.clientIdx]!,
yachtId: p.yachtIdx !== null ? yachtRows[p.yachtIdx]!.id : null,
pipelineStage: p.pipelineStage,
leadCategory: p.leadCategory,
source: p.source,
dateFirstContact: daysAgo(p.daysAgoFirst),
dateLastContact: daysAgo(Math.max(0, p.daysAgoFirst - 2)),
archivedAt: p.archived ? daysAgo(p.daysAgoFirst - 30) : null,
})),
)
.returning({ id: interests.id });
const junctionRows: Array<typeof interestBerths.$inferInsert> = [];
interestPlan.forEach((p, i) => {
if (p.berthIdx === null) return;
const interestId = insertedInterests[i]?.id;
if (!interestId) return;
junctionRows.push({
interestId,
berthId: berthRows[p.berthIdx]!.id,
isPrimary: true,
isSpecificInterest: true,
isInEoiBundle: false,
});
});
if (junctionRows.length > 0) {
await tx.insert(interestBerths).values(junctionRows);
}
// ── 8. Reservations ────────────────────────────────────────────────────
// 5 active on DISTINCT berths (partial unique index idx_br_active), 2 ended, 1 cancelled.
// Active: berths 5..9 (under_offer ones we set earlier).
// Ended: berths 10 and 11 (sold) - use historical start/end dates.
// Cancelled: berth 0 (available - a cancelled res doesn't occupy it).
const activeAssignments: Array<{
berthIdx: number;
clientIdx: number;
yachtIdx: number;
startDaysAgo: number;
}> = [
{ berthIdx: 5, clientIdx: 5, yachtIdx: 3, startDaysAgo: 45 },
{ berthIdx: 6, clientIdx: 6, yachtIdx: 4, startDaysAgo: 65 },
{ berthIdx: 7, clientIdx: 0, yachtIdx: 5, startDaysAgo: 85 },
{ berthIdx: 8, clientIdx: 3, yachtIdx: 6, startDaysAgo: 30 },
{ berthIdx: 9, clientIdx: 6, yachtIdx: 4, startDaysAgo: 20 },
];
const endedAssignments: Array<{
berthIdx: number;
clientIdx: number;
yachtIdx: number;
startDaysAgo: number;
endDaysAgo: number;
}> = [
{ berthIdx: 10, clientIdx: 1, yachtIdx: 1, startDaysAgo: 600, endDaysAgo: 240 },
{ berthIdx: 11, clientIdx: 7, yachtIdx: 11, startDaysAgo: 500, endDaysAgo: 60 },
];
const cancelledAssignment = { berthIdx: 0, clientIdx: 2, yachtIdx: 2, startDaysAgo: 30 };
const reservationValues: Array<typeof berthReservations.$inferInsert> = [];
for (const a of activeAssignments) {
reservationValues.push({
berthId: berthRows[a.berthIdx]!.id,
portId,
clientId: clientIds[a.clientIdx]!,
yachtId: yachtRows[a.yachtIdx]!.id,
status: 'active',
startDate: daysAgo(a.startDaysAgo),
endDate: null,
tenureType: 'permanent',
createdBy: SEED_USER_ID,
});
}
for (const e of endedAssignments) {
reservationValues.push({
berthId: berthRows[e.berthIdx]!.id,
portId,
clientId: clientIds[e.clientIdx]!,
yachtId: yachtRows[e.yachtIdx]!.id,
status: 'ended',
startDate: daysAgo(e.startDaysAgo),
endDate: daysAgo(e.endDaysAgo),
tenureType: 'fixed_term',
createdBy: SEED_USER_ID,
});
}
reservationValues.push({
berthId: berthRows[cancelledAssignment.berthIdx]!.id,
portId,
clientId: clientIds[cancelledAssignment.clientIdx]!,
yachtId: yachtRows[cancelledAssignment.yachtIdx]!.id,
status: 'cancelled',
startDate: daysAgo(cancelledAssignment.startDaysAgo),
endDate: daysAgo(cancelledAssignment.startDaysAgo - 5),
tenureType: 'permanent',
createdBy: SEED_USER_ID,
notes: 'Cancelled by client before activation.',
});
await tx.insert(berthReservations).values(reservationValues);
return {
berths: berthRows.length,
clients: clientRows.length,
companies: companyRows.length,
yachts: yachtRows.length,
interests: interestPlan.length,
reservations: reservationValues.length,
};
});
}