/** * Fix-up: connect the multi-berth links the main dedup pipeline misses. * * The dedup pipeline migrates only each interest's single `Berth Number` text * field; the legacy `_nc_m2m_Berths_Interests` junction (multi-berth deals) is * not carried over by it. This reads that junction from the `nocodb_legacy` * snapshot, resolves each legacy interest → its migrated interest (via the * ledger) and each mooring → the migrated berth, and inserts the missing * `interest_berths` rows. * * Idempotent: `ON CONFLICT (interest_id, berth_id) DO NOTHING`. Primary safety: * only makes a berth primary when the interest has no primary yet (≤1 primary * per interest is a partial unique index). * * Exposed as `connectBerthLinks(...)` so `migrate-from-nocodb.ts --apply` can * fold it into the one-shot seed; also runnable standalone: * * pnpm tsx scripts/migration/connect-berth-links.ts [--port-slug port-nimara] [--dry-run] */ import 'dotenv/config'; import { randomUUID } from 'node:crypto'; import postgres from 'postgres'; const canonMoo = (raw: string): string => { const m = /^([A-Za-z]+)-?0*(\d+)$/.exec((raw ?? '').trim()); return m ? `${m[1]!.toUpperCase()}${parseInt(m[2]!, 10)}` : (raw ?? '').trim(); }; export interface ConnectBerthLinksResult { inserted: number; madePrimary: number; skipped: number; unresolved: string[]; } /** * Self-contained: opens its own CRM + legacy connections (read-only on the * legacy snapshot), does the work, closes them, returns stats. Safe to call * from the runner or standalone. */ export async function connectBerthLinks(opts: { portSlug?: string; dryRun?: boolean; }): Promise { const slug = opts.portSlug ?? 'port-nimara'; const dry = opts.dryRun ?? false; const CRM_URL = process.env.DATABASE_URL!; const LEGACY_URL = process.env.LEGACY_DB_URL ?? CRM_URL.replace(/\/[^/]+$/, '/nocodb_legacy'); const crm = postgres(CRM_URL, { max: 4 }); const legacy = postgres(LEGACY_URL, { max: 4 }); try { const [port] = await crm`select id from ports where slug=${slug} limit 1`; if (!port) throw new Error(`no port ${slug}`); const portId = port.id as string; // legacy junction: interestId → set(moorings) const mooById = new Map(); for (const b of await legacy`select id, "Mooring_Number" m from plplouets5zw1um."Berths"`) mooById.set(b.id as number, canonMoo(b.m as string)); const legacyMoo = new Map>(); for (const j of await legacy`select "Interests_id" i, "Berths_id" b from plplouets5zw1um."_nc_m2m_Berths_Interests"`) { const set = legacyMoo.get(j.i as number) ?? new Set(); const m = mooById.get(j.b as number); if (m) set.add(m); legacyMoo.set(j.i as number, set); } // EOI-signed flag per legacy interest (for is_in_eoi_bundle) const signed = new Set(); for (const r of await legacy`select id, "EOI_Status" e, "LOI_NDA_Document" l from plplouets5zw1um."Interests"`) { const e = ((r.e as string) ?? '').trim(); const l = ((r.l as string) ?? '').trim(); if ( e === 'Signed' || ['Signing Complete', 'Signed by Client', 'Signed by Developer'].includes(l) ) signed.add(r.id as number); } // ledger: legacy interest id → new interest id const links = await crm`select source_id, target_entity_id from migration_source_links where source_system='nocodb_interests' and target_entity_type='interest'`; const newInterestBySrc = new Map( links.map((l) => [Number(l.source_id), l.target_entity_id as string]), ); // CRM berth id by mooring (this port) const berthByMoo = new Map( (await crm`select id, mooring_number m from berths where port_id=${portId}`).map((b) => [ b.m as string, b.id as string, ]), ); let inserted = 0; let madePrimary = 0; let skipped = 0; const unresolved: string[] = []; for (const [legacyId, moorings] of legacyMoo) { const interestId = newInterestBySrc.get(legacyId); if (!interestId) continue; // not a migrated interest (backup/copy tables) const primaryCheck = await crm`select exists(select 1 from interest_berths where interest_id=${interestId} and is_primary) as has`; let hasPrimary = (primaryCheck[0]?.has as boolean | undefined) ?? false; for (const moo of moorings) { const berthId = berthByMoo.get(moo); if (!berthId) { unresolved.push(`${legacyId}:${moo}`); continue; } const makePrimary = !hasPrimary; if (dry) { inserted++; if (makePrimary) { madePrimary++; hasPrimary = true; } continue; } const res = await crm` insert into interest_berths (id, interest_id, berth_id, is_primary, is_specific_interest, is_in_eoi_bundle) values (${randomUUID()}, ${interestId}, ${berthId}, ${makePrimary}, true, ${signed.has(legacyId)}) on conflict (interest_id, berth_id) do nothing returning id`; if (res.length > 0) { inserted++; if (makePrimary) { madePrimary++; hasPrimary = true; } } else { skipped++; } } } return { inserted, madePrimary, skipped, unresolved }; } finally { await crm.end().catch(() => {}); await legacy.end().catch(() => {}); } } // ─── Standalone CLI ────────────────────────────────────────────────────────── function isMain(): boolean { const arg = process.argv[1] ?? ''; return arg.includes('connect-berth-links'); } if (isMain()) { const slugArg = (() => { const i = process.argv.indexOf('--port-slug'); return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara'; })(); const dry = process.argv.includes('--dry-run'); connectBerthLinks({ portSlug: slugArg, dryRun: dry }) .then((r) => { console.log( `connect-berth-links ${dry ? '(DRY)' : ''}: inserted ${r.inserted} links (${r.madePrimary} new primary), ${r.skipped} already linked`, ); if (r.unresolved.length) console.log( ` ⚠ ${r.unresolved.length} moorings with no CRM berth: ${r.unresolved.slice(0, 20).join(', ')}`, ); process.exit(0); }) .catch((e) => { console.error('connect-berth-links failed:', e); process.exit(1); }); }