Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).
Migration 0016 drops:
- clients.nationality
- companies.incorporation_country
- client_addresses.{state_province, country}
- company_addresses.{state_province, country}
Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.
Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.
Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.
Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').
Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1126 lines
34 KiB
TypeScript
1126 lines
34 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:
|
|
*
|
|
* - 12 berths (5 available / 5 reserved-active / 2 sold)
|
|
* - 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,
|
|
documentTemplates,
|
|
} from './schema';
|
|
import {
|
|
getStandardEoiTemplateHtml,
|
|
STANDARD_EOI_MERGE_FIELDS,
|
|
} from '@/lib/pdf/templates/eoi-standard-inapp';
|
|
|
|
// ─── 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 ──────────────────────────────────────────────────────────
|
|
// 12 berths: [0..4] available, [5..9] will be reserved-active, [10..11] sold.
|
|
// We mark 5..9 as 'under_offer' (closest to "reserved via active reservation")
|
|
// and 10..11 as 'sold'; 0..4 remain 'available'.
|
|
const BERTH_SPECS: Array<{
|
|
mooring: string;
|
|
area: string;
|
|
lengthM: string;
|
|
widthM: string;
|
|
draftM: string;
|
|
price: string;
|
|
status: 'available' | 'under_offer' | 'sold';
|
|
}> = [
|
|
{
|
|
mooring: 'A-01',
|
|
area: 'North Pier',
|
|
lengthM: '15',
|
|
widthM: '5',
|
|
draftM: '2.5',
|
|
price: '250000',
|
|
status: 'available',
|
|
},
|
|
{
|
|
mooring: 'A-02',
|
|
area: 'North Pier',
|
|
lengthM: '18',
|
|
widthM: '5.5',
|
|
draftM: '2.8',
|
|
price: '320000',
|
|
status: 'available',
|
|
},
|
|
{
|
|
mooring: 'A-03',
|
|
area: 'North Pier',
|
|
lengthM: '20',
|
|
widthM: '6',
|
|
draftM: '3.0',
|
|
price: '420000',
|
|
status: 'available',
|
|
},
|
|
{
|
|
mooring: 'B-01',
|
|
area: 'Central Basin',
|
|
lengthM: '25',
|
|
widthM: '7',
|
|
draftM: '3.5',
|
|
price: '580000',
|
|
status: 'available',
|
|
},
|
|
{
|
|
mooring: 'B-02',
|
|
area: 'Central Basin',
|
|
lengthM: '30',
|
|
widthM: '8',
|
|
draftM: '4.0',
|
|
price: '780000',
|
|
status: 'available',
|
|
},
|
|
{
|
|
mooring: 'B-03',
|
|
area: 'Central Basin',
|
|
lengthM: '35',
|
|
widthM: '8.5',
|
|
draftM: '4.2',
|
|
price: '950000',
|
|
status: 'under_offer',
|
|
},
|
|
{
|
|
mooring: 'C-01',
|
|
area: 'South Marina',
|
|
lengthM: '40',
|
|
widthM: '9',
|
|
draftM: '4.5',
|
|
price: '1250000',
|
|
status: 'under_offer',
|
|
},
|
|
{
|
|
mooring: 'C-02',
|
|
area: 'South Marina',
|
|
lengthM: '45',
|
|
widthM: '10',
|
|
draftM: '4.8',
|
|
price: '1600000',
|
|
status: 'under_offer',
|
|
},
|
|
{
|
|
mooring: 'C-03',
|
|
area: 'South Marina',
|
|
lengthM: '50',
|
|
widthM: '11',
|
|
draftM: '5.0',
|
|
price: '2100000',
|
|
status: 'under_offer',
|
|
},
|
|
{
|
|
mooring: 'D-01',
|
|
area: 'Superyacht Dock',
|
|
lengthM: '60',
|
|
widthM: '13',
|
|
draftM: '5.5',
|
|
price: '3200000',
|
|
status: 'under_offer',
|
|
},
|
|
{
|
|
mooring: 'D-02',
|
|
area: 'Superyacht Dock',
|
|
lengthM: '70',
|
|
widthM: '14',
|
|
draftM: '6.0',
|
|
price: '4500000',
|
|
status: 'sold',
|
|
},
|
|
{
|
|
mooring: 'D-03',
|
|
area: 'Superyacht Dock',
|
|
lengthM: '80',
|
|
widthM: '15',
|
|
draftM: '6.5',
|
|
price: '6800000',
|
|
status: 'sold',
|
|
},
|
|
];
|
|
|
|
const berthRows = await tx
|
|
.insert(berths)
|
|
.values(
|
|
BERTH_SPECS.map((b) => ({
|
|
portId,
|
|
mooringNumber: b.mooring,
|
|
area: b.area,
|
|
status: b.status,
|
|
lengthM: b.lengthM,
|
|
widthM: b.widthM,
|
|
draftM: b.draftM,
|
|
lengthFt: (Number(b.lengthM) * 3.28084).toFixed(2),
|
|
widthFt: (Number(b.widthM) * 3.28084).toFixed(2),
|
|
draftFt: (Number(b.draftM) * 3.28084).toFixed(2),
|
|
price: b.price,
|
|
priceCurrency: 'USD',
|
|
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: getStandardEoiTemplateHtml(),
|
|
mergeFields: STANDARD_EOI_MERGE_FIELDS,
|
|
isActive: true,
|
|
createdBy: SEED_USER_ID,
|
|
});
|
|
|
|
// ── 7. Interests (15) ──────────────────────────────────────────────────
|
|
// Spread across pipeline stages.
|
|
// Valid stages (from interests schema comment):
|
|
// open, details_sent, in_communication, visited, signed_eoi_nda,
|
|
// deposit_10pct, contract, completed
|
|
// The task spec mentions "open, qualified, hot, won, lost" as logical buckets;
|
|
// map those loosely onto actual stages so we cover variety.
|
|
const interestPlan: Array<{
|
|
clientIdx: number;
|
|
berthIdx: number | null;
|
|
yachtIdx: number | null;
|
|
pipelineStage:
|
|
| 'open'
|
|
| 'details_sent'
|
|
| 'in_communication'
|
|
| 'visited'
|
|
| 'signed_eoi_nda'
|
|
| 'deposit_10pct'
|
|
| 'contract'
|
|
| 'completed';
|
|
leadCategory: 'general_interest' | 'specific_qualified' | 'hot_lead';
|
|
source: 'website' | 'manual' | 'referral' | 'broker';
|
|
daysAgoFirst: number;
|
|
archived?: boolean;
|
|
}> = [
|
|
{
|
|
clientIdx: 0,
|
|
berthIdx: 0,
|
|
yachtIdx: 0,
|
|
pipelineStage: 'open',
|
|
leadCategory: 'general_interest',
|
|
source: 'website',
|
|
daysAgoFirst: 5,
|
|
},
|
|
{
|
|
clientIdx: 1,
|
|
berthIdx: 1,
|
|
yachtIdx: 1,
|
|
pipelineStage: 'details_sent',
|
|
leadCategory: 'general_interest',
|
|
source: 'website',
|
|
daysAgoFirst: 12,
|
|
},
|
|
{
|
|
clientIdx: 2,
|
|
berthIdx: 2,
|
|
yachtIdx: 2,
|
|
pipelineStage: 'in_communication',
|
|
leadCategory: 'specific_qualified',
|
|
source: 'referral',
|
|
daysAgoFirst: 25,
|
|
},
|
|
{
|
|
clientIdx: 3,
|
|
berthIdx: 3,
|
|
yachtIdx: 6,
|
|
pipelineStage: 'visited',
|
|
leadCategory: 'specific_qualified',
|
|
source: 'referral',
|
|
daysAgoFirst: 40,
|
|
},
|
|
{
|
|
clientIdx: 4,
|
|
berthIdx: 4,
|
|
yachtIdx: null,
|
|
pipelineStage: 'open',
|
|
leadCategory: 'general_interest',
|
|
source: 'broker',
|
|
daysAgoFirst: 8,
|
|
},
|
|
{
|
|
clientIdx: 5,
|
|
berthIdx: 5,
|
|
yachtIdx: 3,
|
|
pipelineStage: 'signed_eoi_nda',
|
|
leadCategory: 'hot_lead',
|
|
source: 'manual',
|
|
daysAgoFirst: 55,
|
|
},
|
|
{
|
|
clientIdx: 6,
|
|
berthIdx: 6,
|
|
yachtIdx: 4,
|
|
pipelineStage: 'deposit_10pct',
|
|
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: 'completed',
|
|
leadCategory: 'hot_lead',
|
|
source: 'referral',
|
|
daysAgoFirst: 240,
|
|
},
|
|
{
|
|
clientIdx: 7,
|
|
berthIdx: 11,
|
|
yachtIdx: 11,
|
|
pipelineStage: 'completed',
|
|
leadCategory: 'hot_lead',
|
|
source: 'manual',
|
|
daysAgoFirst: 320,
|
|
},
|
|
{
|
|
clientIdx: 2,
|
|
berthIdx: null,
|
|
yachtIdx: null,
|
|
pipelineStage: 'open',
|
|
leadCategory: 'general_interest',
|
|
source: 'website',
|
|
daysAgoFirst: 3,
|
|
},
|
|
{
|
|
clientIdx: 3,
|
|
berthIdx: 8,
|
|
yachtIdx: 6,
|
|
pipelineStage: 'in_communication',
|
|
leadCategory: 'specific_qualified',
|
|
source: 'website',
|
|
daysAgoFirst: 18,
|
|
},
|
|
{
|
|
clientIdx: 5,
|
|
berthIdx: null,
|
|
yachtIdx: 3,
|
|
pipelineStage: 'details_sent',
|
|
leadCategory: 'general_interest',
|
|
source: 'referral',
|
|
daysAgoFirst: 10,
|
|
},
|
|
// "Lost" — modeled as archived + open stage
|
|
{
|
|
clientIdx: 4,
|
|
berthIdx: 2,
|
|
yachtIdx: null,
|
|
pipelineStage: 'open',
|
|
leadCategory: 'general_interest',
|
|
source: 'website',
|
|
daysAgoFirst: 180,
|
|
archived: true,
|
|
},
|
|
{
|
|
clientIdx: 6,
|
|
berthIdx: 9,
|
|
yachtIdx: 4,
|
|
pipelineStage: 'visited',
|
|
leadCategory: 'specific_qualified',
|
|
source: 'broker',
|
|
daysAgoFirst: 45,
|
|
},
|
|
];
|
|
|
|
await tx.insert(interests).values(
|
|
interestPlan.map((p) => ({
|
|
portId,
|
|
clientId: clientIds[p.clientIdx]!,
|
|
berthId: p.berthIdx !== null ? berthRows[p.berthIdx]!.id : null,
|
|
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,
|
|
})),
|
|
);
|
|
|
|
// ── 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,
|
|
};
|
|
});
|
|
}
|