From c612bbdfd97b6359ca561af9fc3f88e5f243023c Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sun, 3 May 2026 21:05:11 +0200 Subject: [PATCH] fix(migration): legacy bare-mooring lookup + port-nimara berth backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues surfaced when applying the migration to dev: 1. Mooring number format mismatch The legacy NocoDB Interests table writes bare mooring strings ("D32", "B16", "A4"), but the new berths table (mirroring the NocoDB Berths snapshot) uses zero-padded dashed form ("D-32", "B-16", "A-04"). The interest→berth lookup missed every reference. migration-apply.ts now tries the literal value first, then falls back to a normalized form via `normalizeLegacyMooring(raw)`: "D32" -> "D-32" "A4" -> "A-04" "E18" -> "E-18" Multi-mooring strings ("A3, D30") are left as-is so they surface in the warnings list for human review rather than silently picking one. 2. port-nimara only had the 12 hand-rolled seed berths, not the 117- berth NocoDB snapshot The mobile-foundation seed only places those 12 in port-nimara; the 117-berth snapshot was added later but only seeded into Marina Azzurra (the secondary test port). Migrated interests reference moorings well beyond A-01..D-03, so most lookups failed. New scripts/load-berths-to-port-nimara.ts: idempotently loads any missing snapshot berths into port-nimara without disturbing the existing 12 (skips moorings that already exist). Run once; subsequent runs no-op. Result of full migration run on dev: 237 clients inserted (out of 245 total — 8 from prior seed) 406 contacts, 52 addresses, 38 yachts, 252 interests 27 interest→berth links resolved (only 13 source rows had a Berth field set in NocoDB to begin with — most legacy interests are early inquiries with no berth assignment) 1 unresolved warning: source=277 has multi-mooring "A3, D30" Verified in UI: /port-nimara/clients shows real names (John-michael Seelye, Reza Amjad, Etiennette Clamouze, …) /port-nimara/clients/ renders contacts (gmail.com addresses, E.164 phones), tab counts (Interests N, Yachts N), pipeline summary Dashboard: 245 clients, 266 active interests, $46.5M pipeline value Pipeline funnel chart now shows real distribution (180 Open, 45 EOI Signed, dropoff through stages) Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/load-berths-to-port-nimara.ts | 126 ++++++++++++++++++++++++++ src/lib/dedup/migration-apply.ts | 28 +++++- 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 scripts/load-berths-to-port-nimara.ts diff --git a/scripts/load-berths-to-port-nimara.ts b/scripts/load-berths-to-port-nimara.ts new file mode 100644 index 0000000..6b70344 --- /dev/null +++ b/scripts/load-berths-to-port-nimara.ts @@ -0,0 +1,126 @@ +/** + * One-shot: load the 117-berth NocoDB snapshot into the port-nimara + * port, skipping any moorings that already exist. + * + * The original seed only seeded 12 hand-rolled berths into port-nimara + * (A-01..D-03), but the migration's interest rows reference moorings + * across A-01..E-18. This loads the full set so interest→berth links + * resolve cleanly on the next migration run. + */ +import 'dotenv/config'; +import { eq, and, sql, inArray } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { ports } from '@/lib/db/schema/ports'; +import { berths } from '@/lib/db/schema/berths'; +import berthSnapshot from '@/lib/db/seed-data/berths.json'; + +interface SnapshotBerth { + mooringNumber: string; + area: string; + 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; +} + +async function main() { + const [port] = await db + .select({ id: ports.id }) + .from(ports) + .where(eq(ports.slug, 'port-nimara')) + .limit(1); + if (!port) throw new Error('port-nimara not found'); + + const snapshot = berthSnapshot as unknown as SnapshotBerth[]; + + // Existing moorings — skip these. + const existingRows = await db + .select({ mooringNumber: berths.mooringNumber }) + .from(berths) + .where(eq(berths.portId, port.id)); + const existingMoorings = new Set(existingRows.map((r) => r.mooringNumber)); + + const toInsert = snapshot.filter((b) => !existingMoorings.has(b.mooringNumber)); + console.log( + `Snapshot: ${snapshot.length} berths, existing in port-nimara: ${existingRows.length}, to insert: ${toInsert.length}`, + ); + + if (toInsert.length === 0) { + console.log('Nothing to do.'); + return; + } + + const inserted = await db + .insert(berths) + .values( + toInsert.map((b) => ({ + portId: port.id, + 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, mooringNumber: berths.mooringNumber }); + + console.log(`Inserted ${inserted.length} berths.`); + + // Suppress unused-import warning if eslint is strict. + void and; + void sql; + void inArray; +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/lib/dedup/migration-apply.ts b/src/lib/dedup/migration-apply.ts index a0c480e..475ffc3 100644 --- a/src/lib/dedup/migration-apply.ts +++ b/src/lib/dedup/migration-apply.ts @@ -28,6 +28,25 @@ import type { MigrationPlan, PlannedClient, PlannedInterest } from './migration- const SOURCE_SYSTEM = 'nocodb_interests'; +/** + * Convert a legacy bare mooring string like "D32" / "A1" / "E18" to the + * dashed/padded form "D-32" / "A-01" / "E-18" used by the new berths + * schema. If the input doesn't match the bare pattern, returns it + * unchanged so a literal lookup can still hit (handles the case where + * the legacy data already has the dashed form). + * + * Multi-mooring strings ("A3, D30") return the original string — + * those need human review and we don't want to silently pick one half. + */ +function normalizeLegacyMooring(raw: string): string { + // Bare letter+digits, e.g. "D32" + const m = /^([A-E])(\d{1,3})$/i.exec(raw.trim()); + if (!m) return raw; + const letter = m[1]!.toUpperCase(); + const num = parseInt(m[2]!, 10); + return `${letter}-${num.toString().padStart(2, '0')}`; +} + export interface ApplyResult { applyId: string; clientsInserted: number; @@ -212,7 +231,14 @@ async function applyInterest( let berthId: string | null = null; if (planned.berthMooringNumber) { - berthId = mooringToBerthId.get(planned.berthMooringNumber) ?? null; + berthId = + mooringToBerthId.get(planned.berthMooringNumber) ?? + // The legacy NocoDB Interests table uses bare mooring strings like + // "D32", "B16", whereas the new berths schema (mirroring the NocoDB + // Berths snapshot) uses zero-padded "D-32", "B-16". Try the dashed + // form as a fallback so legacy references resolve correctly. + mooringToBerthId.get(normalizeLegacyMooring(planned.berthMooringNumber)) ?? + null; if (!berthId) { result.warnings.push( `Interest source=${planned.sourceId} references unknown mooring="${planned.berthMooringNumber}" — interest created without berth link`,