feat(migration): old-LOI EOI recovery, folded berth-links, contactless flag

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>
This commit is contained in:
2026-06-01 22:18:28 +02:00
parent 8be7a6e29d
commit a343eaa257
4 changed files with 399 additions and 125 deletions

View File

@@ -19,6 +19,7 @@
import 'dotenv/config';
import { randomUUID } from 'node:crypto';
import { Client as MinioClient } from 'minio';
import postgres from 'postgres';
import { and, eq, isNull } from 'drizzle-orm';
import { db, closeDb } from '@/lib/db';
@@ -26,6 +27,8 @@ import { ports } from '@/lib/db/schema/ports';
import { berths } from '@/lib/db/schema/berths';
import { documents, files } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
import { getStorageBackend } from '@/lib/storage';
import { buildStoragePath } from '@/lib/minio';
import { ensureEntityFolder } from '@/lib/services/document-folders.service';
@@ -40,6 +43,8 @@ const slugArg = (() => {
})();
const LEGACY_BUCKET = process.env.LEGACY_MINIO_BUCKET ?? 'client-portal';
// NocoDB's own attachment store — where pre-Documenso "LOI process" EOIs live.
const DATABASE_BUCKET = process.env.LEGACY_MINIO_DATABASE_BUCKET ?? 'database';
const legacy = new MinioClient({
endPoint: process.env.LEGACY_MINIO_ENDPOINT ?? 's3.portnimara.com',
port: 443,
@@ -48,6 +53,11 @@ const legacy = new MinioClient({
secretKey: process.env.LEGACY_MINIO_SECRET_KEY ?? '',
});
// Read-only connection to the LOCAL restored NocoDB dump (`nocodb_legacy`) —
// used to read the `EOI_Document` attachment metadata. Never prod.
const CRM_DB_URL = process.env.DATABASE_URL ?? '';
const LEGACY_DB_URL = process.env.LEGACY_DB_URL ?? CRM_DB_URL.replace(/\/[^/]+$/, '/nocodb_legacy');
/** Levenshtein edit distance — conservative fuzzy name matching for legacy
* spelling/format drift (Koshbin↔Khoshbin, Costanzo↔Constanzo). */
function lev(a: string, b: string): number {
@@ -275,6 +285,171 @@ async function backfillEois(port: { id: string; slug: string }) {
};
}
// ─── Old-LOI EOIs (NocoDB `database` bucket attachments) ─────────────────────
// The ~10 pre-Documenso "LOI process" deals have no documensoID and no curated
// client-portal/EOIs copy; their signed PDF lives only as a NocoDB attachment
// in the `database` bucket. The main pipeline keys EOI-doc creation off
// documensoID, so it never created a document row for them. Here we CREATE the
// document + file + folder and link the recovered PDF. Idempotent via a
// `nocodb_eoi_document` ledger entry per legacy interest.
function legacyKeyFromUrl(url: string): string | null {
// https://<host>/database/nc/uploads/... → nc/uploads/...
const marker = `/${DATABASE_BUCKET}/`;
const i = url.indexOf(marker);
if (i < 0) return null;
return decodeURIComponent(url.slice(i + marker.length));
}
async function backfillOldLoiEois(
port: { id: string; slug: string },
legacyDb: ReturnType<typeof postgres>,
) {
const rows = (await legacyDb`
select id, "EOI_Document"::text as doc
from plplouets5zw1um."Interests"
where "EOI_Document" is not null and "EOI_Document"::text not in ('', '[]', 'null')
`) as unknown as Array<{ id: number; doc: string }>;
const backend = await getStorageBackend();
let created = 0;
let skipped = 0;
let unmatched = 0;
const unresolved: string[] = [];
for (const r of rows) {
let url: string | null = null;
let title: string | null = null;
try {
const parsed = JSON.parse(r.doc) as unknown;
const first = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
if (first && typeof first === 'object') {
const rec = first as Record<string, unknown>;
if (typeof rec.url === 'string') url = rec.url;
if (typeof rec.title === 'string') title = rec.title;
}
} catch {
// ignore malformed attachment JSON
}
const key = url ? legacyKeyFromUrl(url) : null;
if (!key) {
unmatched++;
continue;
}
// legacy interest id → migrated interest
const [link] = await db
.select({ interestId: migrationSourceLinks.targetEntityId })
.from(migrationSourceLinks)
.where(
and(
eq(migrationSourceLinks.sourceSystem, 'nocodb_interests'),
eq(migrationSourceLinks.sourceId, String(r.id)),
eq(migrationSourceLinks.targetEntityType, 'interest'),
),
)
.limit(1);
if (!link) {
unresolved.push(`legacy#${r.id} (not a migrated interest)`);
unmatched++;
continue;
}
const interestId = link.interestId;
// Idempotency: skip if this attachment was already recovered.
const [already] = await db
.select({ id: migrationSourceLinks.id })
.from(migrationSourceLinks)
.where(
and(
eq(migrationSourceLinks.sourceSystem, 'nocodb_eoi_document'),
eq(migrationSourceLinks.sourceId, String(r.id)),
eq(migrationSourceLinks.targetEntityType, 'document'),
),
)
.limit(1);
if (already) {
skipped++;
continue;
}
const [intRow] = await db
.select({ clientId: interests.clientId, yachtId: interests.yachtId })
.from(interests)
.where(eq(interests.id, interestId))
.limit(1);
if (!intRow?.clientId) {
unmatched++;
continue;
}
const clientId = intRow.clientId;
if (DRY) {
created++;
continue;
}
const buf = await streamToBuffer(await legacy.getObject(DATABASE_BUCKET, key));
const docId = randomUUID();
const storageKey = buildStoragePath(port.slug, 'eoi-signed', docId, randomUUID(), 'pdf');
const putRes = await backend.put(storageKey, buf, {
contentType: 'application/pdf',
sizeBytes: buf.length,
});
const folder = await ensureEntityFolder(port.id, 'client', clientId, SUPER_ADMIN_USER_ID);
const fileName = title || key.split('/').pop() || 'eoi-signed.pdf';
await db.transaction(async (tx) => {
const [f] = await tx
.insert(files)
.values({
portId: port.id,
filename: fileName,
originalName: fileName,
storagePath: putRes.key,
mimeType: 'application/pdf',
sizeBytes: String(putRes.sizeBytes),
category: 'eoi',
folderId: folder.id,
clientId,
interestId,
uploadedBy: 'system',
})
.returning({ id: files.id });
if (!f) throw new Error('files insert returned no row');
await tx.insert(documents).values({
id: docId,
portId: port.id,
interestId,
clientId,
yachtId: intRow.yachtId ?? null,
documentType: 'eoi',
title: `External EOI (legacy) - ${fileName}`,
status: 'completed',
isManualUpload: true,
signedFileId: f.id,
createdBy: SUPER_ADMIN_USER_ID,
});
await tx
.update(interests)
.set({ eoiDocStatus: 'signed', updatedAt: new Date() })
.where(eq(interests.id, interestId));
await tx.insert(migrationSourceLinks).values({
sourceSystem: 'nocodb_eoi_document',
sourceId: String(r.id),
targetEntityType: 'document',
targetEntityId: docId,
appliedId: `oldloi-${docId}`,
appliedBy: SUPER_ADMIN_USER_ID,
});
});
created++;
}
return { total: rows.length, created, skipped, unmatched, unresolved };
}
async function main() {
if (!process.env.LEGACY_MINIO_ACCESS_KEY || !process.env.LEGACY_MINIO_SECRET_KEY) {
console.error(
@@ -303,6 +478,20 @@ async function main() {
for (const n of eoiRes.unresolved.slice(0, 25)) console.log(` - ${n}`);
}
console.log('[backfill] Old-LOI EOIs (NocoDB `database` bucket)…');
const legacyDb = postgres(LEGACY_DB_URL, { max: 2 });
try {
const loiRes = await backfillOldLoiEois(port, legacyDb);
console.log(
` old-LOI EOIs: ${loiRes.total} attachments → ${loiRes.created} created, ${loiRes.skipped} already done, ${loiRes.unmatched} unmatched`,
);
if (loiRes.unresolved.length > 0) {
for (const n of loiRes.unresolved.slice(0, 25)) console.log(` - ${n}`);
}
} finally {
await legacyDb.end().catch(() => {});
}
await closeDb();
process.exit(0);
}