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,13 +19,14 @@
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients';
import { clients, clientContacts, clientAddresses, clientTags } from '@/lib/db/schema/clients';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { yachts } from '@/lib/db/schema/yachts';
import { berths } from '@/lib/db/schema/berths';
import { documents, documentSigners } from '@/lib/db/schema/documents';
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
import { expenses } from '@/lib/db/schema/financial';
import { tags } from '@/lib/db/schema/system';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
import type {
MigrationPlan,
@@ -38,6 +39,29 @@ import type {
const SOURCE_SYSTEM = 'nocodb_interests';
/** Tag applied to legacy clients that arrived with no email/phone, so staff
* can filter + chase them. Kept here (not in the transform) because it's an
* apply-side side effect, not part of the canonical planned shape. */
const CONTACTLESS_TAG_NAME = 'Needs contact info';
const contactlessTagByPort = new Map<string, string>();
async function ensureContactlessTag(portId: string): Promise<string> {
const cached = contactlessTagByPort.get(portId);
if (cached) return cached;
await db
.insert(tags)
.values({ portId, name: CONTACTLESS_TAG_NAME, color: '#F59E0B' })
.onConflictDoNothing();
const [row] = await db
.select({ id: tags.id })
.from(tags)
.where(and(eq(tags.portId, portId), eq(tags.name, CONTACTLESS_TAG_NAME)))
.limit(1);
if (!row) throw new Error('failed to ensure contactless tag');
contactlessTagByPort.set(portId, row.id);
return row.id;
}
/**
* Canonicalize a mooring string to the unified form ("A1", "D32", "E18")
* used by the new berths schema after the Phase 0 mooring-number sweep.
@@ -223,6 +247,14 @@ async function applyClient(
result.addressesInserted += addressRows.length;
}
// Flag legacy records that arrived with no contact info so staff can chase
// them. The dedup cluster aggregates all source rows, so an empty contacts
// list means truly no email/phone for this person across every merged row.
if (planned.contacts.length === 0) {
const tagId = await ensureContactlessTag(opts.port.id);
await db.insert(clientTags).values({ clientId, tagId }).onConflictDoNothing();
}
result.clientsInserted += 1;
return { clientId, inserted: true };
}