fix(migration): legacy bare-mooring lookup + port-nimara berth backfill
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/<id> 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) <noreply@anthropic.com>
This commit is contained in:
126
scripts/load-berths-to-port-nimara.ts
Normal file
126
scripts/load-berths-to-port-nimara.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -28,6 +28,25 @@ import type { MigrationPlan, PlannedClient, PlannedInterest } from './migration-
|
|||||||
|
|
||||||
const SOURCE_SYSTEM = 'nocodb_interests';
|
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 {
|
export interface ApplyResult {
|
||||||
applyId: string;
|
applyId: string;
|
||||||
clientsInserted: number;
|
clientsInserted: number;
|
||||||
@@ -212,7 +231,14 @@ async function applyInterest(
|
|||||||
|
|
||||||
let berthId: string | null = null;
|
let berthId: string | null = null;
|
||||||
if (planned.berthMooringNumber) {
|
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) {
|
if (!berthId) {
|
||||||
result.warnings.push(
|
result.warnings.push(
|
||||||
`Interest source=${planned.sourceId} references unknown mooring="${planned.berthMooringNumber}" — interest created without berth link`,
|
`Interest source=${planned.sourceId} references unknown mooring="${planned.berthMooringNumber}" — interest created without berth link`,
|
||||||
|
|||||||
Reference in New Issue
Block a user