Three polish items so the legacy seed is one-shot and complete: - backfill-documents: recover the ~10 pre-Documenso "LOI process" EOIs whose signed PDF lives only as a NocoDB attachment in the `database` MinIO bucket (the pipeline keys EOI-doc creation off documensoID, so it never created rows for them). Reads EOI_Document attachment metadata from the local nocodb_legacy dump, pulls the PDF (read-only) from the `database` bucket, and CREATES the document + file + folder, linking the signed PDF. Idempotent via a `nocodb_eoi_document` ledger entry. - connect-berth-links: refactored into an exported connectBerthLinks() and folded into migrate-from-nocodb --apply (best-effort; skips with a warning if the local dump isn't restored) so the multi-berth junction is reconnected as part of the one-shot seed, not a separate manual step. - migration-apply: contactless legacy clients (no email/phone across the whole dedup cluster) get a per-port "Needs contact info" tag so staff can filter + chase them, instead of being dropped. The current dev DB's 29 contactless clients were tagged via a one-off mirroring the pipeline logic. EOI recovery code is ready but the actual run needs LEGACY_MINIO_* read creds supplied at the command line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
176 lines
6.5 KiB
TypeScript
176 lines
6.5 KiB
TypeScript
/**
|
|
* 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<ConnectBerthLinksResult> {
|
|
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<number, string>();
|
|
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<number, Set<string>>();
|
|
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<string>();
|
|
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<number>();
|
|
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);
|
|
});
|
|
}
|