Files
pn-new-crm/src/lib/dedup/migration-transform.ts
Matt Ciaccio d62822c284 fix(migration): NocoDB import safety + dedup helpers + lead-source backfill
migration-apply: residential client + interest inserts now wrap in
db.transaction so a partial failure can't leave an orphan client
row without its interest (or vice versa).

migration-transform: buildPlannedDocument returns null when there
are no signers so the apply pass doesn't try to send a Documenso
envelope without recipients. mapDocumentStatus gets an explicit
"Awaiting Further Details" branch that no longer auto-promotes via
stale sign-time fields. parseFlexibleDate handles ISO and DD-MM-YYYY
inputs uniformly.

backfill-legacy-lead-source: chunk UPDATE WHERE clause now
isNull(source) on top of the inArray match, so a re-run can't
overwrite a more accurate source written between batches.

Adds 235 lines of vitest coverage on migration-transform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:56:18 +02:00

865 lines
31 KiB
TypeScript

/**
* Pure transform: NocoDB snapshot → planned new-system entities + dedup result.
*
* Used by the migration script's `--dry-run` (to produce the report) and
* `--apply` (to actually write). Keeping this pure means the same code
* runs in both modes, in tests against the frozen fixture, and in the
* one-off CLI run against the live base.
*
* No side effects, no DB calls, no external services.
*/
import {
normalizeName,
normalizeEmail,
normalizePhone,
resolveCountry,
type NormalizedPhone,
} from './normalize';
import { findClientMatches, type MatchCandidate } from './find-matches';
import type { CountryCode } from '@/lib/i18n/countries';
import type { NocoDbRow, NocoDbSnapshot } from './nocodb-source';
// ─── Plan output ────────────────────────────────────────────────────────────
export interface PlannedClient {
/** Stable id derived from the deduped cluster's lead row. Used by the
* apply phase to reference newly-created clients before they exist
* in the DB. */
tempId: string;
/** Source row IDs that contributed to this client (one if no duplicates,
* many if dedup merged a cluster). */
sourceIds: number[];
fullName: string;
surnameToken?: string;
countryIso: CountryCode | null;
preferredContactMethod: string | null;
source: string | null;
contacts: PlannedContact[];
addresses: PlannedAddress[];
}
export interface PlannedContact {
channel: 'email' | 'phone' | 'whatsapp' | 'other';
value: string;
valueE164?: string | null;
valueCountry?: CountryCode | null;
isPrimary: boolean;
flagged?: string;
}
export interface PlannedAddress {
streetAddress: string | null;
city: string | null;
countryIso: CountryCode | null;
/** When confidence is low, the migration script flags the row for
* human review. */
countryConfidence: 'exact' | 'fuzzy' | 'city' | 'fallback' | null;
}
export interface PlannedInterest {
/** NocoDB row id this interest came from. */
sourceId: number;
/** tempId of the planned client this interest hangs off. */
clientTempId: string;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
notes: string | null;
/** Mooring number; the apply phase resolves this to a berthId via the
* new-system Berths table. */
berthMooringNumber: string | null;
yachtName: string | null;
/** Date stamps for milestone columns. ISO strings if parseable. */
dateEoiSent: string | null;
dateEoiSigned: string | null;
dateDepositReceived: string | null;
dateContractSent: string | null;
dateContractSigned: string | null;
dateLastContact: string | null;
/** Documenso linkage carried forward when present so the document
* record can be stitched up downstream. */
documensoId: string | null;
}
/**
* EOI document derived from a legacy NocoDB Interests row that carries a
* `documensoID`. The apply phase materializes this into one
* `documents` row plus up to three `document_signers` rows (client / cc /
* developer), preserving the legacy signing-link URLs and timestamps.
*
* Carries the same `sourceId` as the parent interest - apply uses that to
* resolve the new `interest_id` and `client_id` via
* `migration_source_links`.
*/
export interface PlannedDocument {
sourceId: number;
/** tempId of the parent client (used for client_id resolution). */
clientTempId: string;
documentType: 'eoi';
title: string;
/** new-system document.status. Mapped from the legacy `EOI Status`
* enum + sign-time fields. */
status: 'draft' | 'sent' | 'partially_signed' | 'completed';
documensoId: string;
notes: string | null;
signers: PlannedDocumentSigner[];
/** Mirror of interest's dateEoiSent; useful for back-dating createdAt. */
dateSent: string | null;
}
export interface PlannedDocumentSigner {
signerName: string;
signerEmail: string;
signerRole: 'client' | 'cc' | 'developer';
signingOrder: number;
status: 'pending' | 'signed';
signedAt: string | null;
signingUrl: string | null;
embeddedUrl: string | null;
}
/**
* Residential lead from the legacy "Interests (Residences)" table. Pure
* contact record (no pipeline data in legacy), so apply creates a
* `residential_clients` row plus a default `residential_interests` row at
* `pipeline_stage='new'` so it surfaces in the residential funnel.
*/
export interface PlannedResidentialClient {
/** Legacy residential row id - used as the migration_source_links key. */
sourceId: number;
fullName: string;
email: string | null;
phoneE164: string | null;
phoneCountry: CountryCode | null;
placeOfResidence: string | null;
placeOfResidenceCountryIso: CountryCode | null;
source: string | null;
notes: string | null;
dateFirstContact: string | null;
}
export interface MigrationFlag {
sourceTable: 'interests' | 'residential_interests' | 'website_interest_submissions';
sourceId: number;
reason: string;
details?: Record<string, unknown>;
}
export interface MigrationPlan {
clients: PlannedClient[];
interests: PlannedInterest[];
/** EOI documents derived from interest rows with a `documensoID`. */
documents: PlannedDocument[];
/** Residential leads - physically separate domain, simple 1:1 mapping. */
residentialClients: PlannedResidentialClient[];
flags: MigrationFlag[];
/** Pairs that the migration would auto-link (high score). */
autoLinks: Array<{
leadSourceId: number;
mergedSourceIds: number[];
score: number;
reasons: string[];
}>;
/** Pairs that need human review (medium score). Each pair shows up
* in the migration report; the user resolves before --apply. */
needsReview: Array<{ aSourceId: number; bSourceId: number; score: number; reasons: string[] }>;
stats: MigrationStats;
}
export interface MigrationStats {
inputInterestRows: number;
inputResidentialRows: number;
outputClients: number;
outputInterests: number;
outputContacts: number;
outputAddresses: number;
outputDocuments: number;
outputDocumentSigners: number;
outputResidentialClients: number;
flaggedRows: number;
autoLinkedClusters: number;
needsReviewPairs: number;
}
export interface TransformOptions {
/** ISO country used when a phone has no prefix and the row has no
* Place of Residence. Defaults to AI (Anguilla / Port Nimara's home). */
defaultPhoneCountry: CountryCode;
/** Score thresholds for auto-link vs human review. Should match the
* per-port `system_settings` values once the runtime UI is in place. */
thresholds: {
autoLink: number;
needsReview: number;
};
}
const DEFAULT_OPTIONS: TransformOptions = {
defaultPhoneCountry: 'AI',
thresholds: { autoLink: 90, needsReview: 50 },
};
// ─── Stage mapping ──────────────────────────────────────────────────────────
const STAGE_MAP: Record<string, string> = {
'General Qualified Interest': 'open',
'Specific Qualified Interest': 'details_sent',
'EOI and NDA Sent': 'eoi_sent',
'Signed EOI and NDA': 'eoi_signed',
'Made Reservation': 'deposit_10pct',
'Contract Negotiation': 'contract_sent',
'Contract Negotiations Finalized': 'contract_sent',
'Contract Signed': 'contract_signed',
};
const LEAD_CATEGORY_MAP: Record<string, string> = {
General: 'general_interest',
'Friends and Family': 'general_interest',
};
const SOURCE_MAP: Record<string, string> = {
portal: 'website',
Form: 'website',
External: 'manual',
};
// ─── Date parsing ───────────────────────────────────────────────────────────
/**
* Parse a date the legacy NocoDB might have stored in DD-MM-YYYY,
* DD/MM/YYYY, YYYY-MM-DD, or ISO format. Returns ISO string or null.
*/
function parseFlexibleDate(input: unknown): string | null {
if (typeof input !== 'string' || input.trim() === '') return null;
const s = input.trim();
// Already ISO
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
// DD-MM-YYYY or DD/MM/YYYY
const m = s.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/);
if (m) {
const [, day, month, year] = m;
const iso = `${year}-${month!.padStart(2, '0')}-${day!.padStart(2, '0')}`;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
// Anything else: try Date constructor as a last resort
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
// ─── Main transform ─────────────────────────────────────────────────────────
/**
* Run the full transform pipeline against a NocoDB snapshot. Pure
* function - same input always produces the same plan.
*/
export function transformSnapshot(
snapshot: NocoDbSnapshot,
options: Partial<TransformOptions> = {},
): MigrationPlan {
const opts = { ...DEFAULT_OPTIONS, ...options };
const flags: MigrationFlag[] = [];
// Build per-row candidates first so we can run dedup before assigning
// tempIds (clients with multiple source rows merge into one tempId).
const perRow = snapshot.interests.map((row) => rowToCandidate(row, 'interests', opts, flags));
// Dedup pass 1: every row scored against every other row (within the
// same pool). The blocking strategy in `findClientMatches` keeps this
// cheap even for the full 252-row dataset.
const clusters = clusterByDedup(perRow, opts);
// Build the planned clients + interests from the clusters.
const clients: PlannedClient[] = [];
const interests: PlannedInterest[] = [];
const documents: PlannedDocument[] = [];
const autoLinks: MigrationPlan['autoLinks'] = [];
const needsReview: MigrationPlan['needsReview'] = [];
for (const cluster of clusters) {
const lead = cluster.leadCandidate;
const tempId = `client-${lead.row.Id}`;
// Build the client record from the lead row, then merge in any
// contact info / address info from the other rows in the cluster.
const planned = buildPlannedClient(tempId, cluster, opts);
clients.push(planned);
// Each row in the cluster becomes its own interest record. If the
// legacy row carried a documensoID, also emit an EOI document so the
// /documents view in the new CRM mirrors the legacy signing state.
for (const member of cluster.members) {
const interest = buildPlannedInterest(member.row, tempId);
interests.push(interest);
const doc = buildPlannedDocument(member.row, tempId, planned.fullName);
if (doc) documents.push(doc);
}
if (cluster.members.length > 1) {
autoLinks.push({
leadSourceId: lead.row.Id,
mergedSourceIds: cluster.members.filter((m) => m !== lead).map((m) => m.row.Id),
score: cluster.maxScore,
reasons: cluster.reasons,
});
}
for (const pair of cluster.reviewPairs) {
needsReview.push(pair);
}
}
// Residential leads - separate domain, no dedup needed (different team
// sees different rows). One PlannedResidentialClient per source row.
const residentialClients: PlannedResidentialClient[] = snapshot.residentialInterests
.map((row) => buildPlannedResidentialClient(row, opts, flags))
.filter((r): r is PlannedResidentialClient => r !== null);
return {
clients,
interests,
documents,
residentialClients,
flags,
autoLinks,
needsReview,
stats: {
inputInterestRows: snapshot.interests.length,
inputResidentialRows: snapshot.residentialInterests.length,
outputClients: clients.length,
outputInterests: interests.length,
outputContacts: clients.reduce((sum, c) => sum + c.contacts.length, 0),
outputAddresses: clients.reduce((sum, c) => sum + c.addresses.length, 0),
outputDocuments: documents.length,
outputDocumentSigners: documents.reduce((sum, d) => sum + d.signers.length, 0),
outputResidentialClients: residentialClients.length,
flaggedRows: flags.length,
autoLinkedClusters: autoLinks.length,
needsReviewPairs: needsReview.length,
},
};
}
// ─── Helpers ────────────────────────────────────────────────────────────────
interface RowCandidate {
row: NocoDbRow;
candidate: MatchCandidate;
/** Phone normalize result for the row's primary phone; used downstream
* to attach valueE164 + country to the planned contact. */
phoneResult: NormalizedPhone | null;
/** Country resolved from "Place of Residence". */
countryIso: CountryCode | null;
countryConfidence: 'exact' | 'fuzzy' | 'city' | null;
/** Normalized email or null. */
email: string | null;
/** Display name from `normalizeName`. */
displayName: string;
}
function rowToCandidate(
row: NocoDbRow,
sourceTable: MigrationFlag['sourceTable'],
opts: TransformOptions,
flags: MigrationFlag[],
): RowCandidate {
const rawName = (row['Full Name'] as string | undefined) ?? '';
const rawEmail = (row['Email Address'] as string | undefined) ?? '';
const rawPhone = (row['Phone Number'] as string | undefined) ?? '';
const rawCountry = (row['Place of Residence'] as string | undefined) ?? '';
const normName = normalizeName(rawName);
const email = normalizeEmail(rawEmail);
const country = resolveCountry(rawCountry);
const phoneCountry = country.iso ?? opts.defaultPhoneCountry;
const phoneResult = normalizePhone(rawPhone, phoneCountry as CountryCode);
// Surface anything weird so the report can show it.
if (rawPhone && !phoneResult?.e164) {
flags.push({
sourceTable,
sourceId: row.Id,
reason: phoneResult?.flagged ? `phone ${phoneResult.flagged}` : 'phone unparseable',
details: { rawPhone },
});
}
if (rawEmail && !email) {
flags.push({
sourceTable,
sourceId: row.Id,
reason: 'email invalid',
details: { rawEmail },
});
}
if (rawCountry && !country.iso) {
flags.push({
sourceTable,
sourceId: row.Id,
reason: 'country unresolved',
details: { rawCountry },
});
}
const candidate: MatchCandidate = {
id: String(row.Id),
fullName: normName.display || null,
surnameToken: normName.surnameToken ?? null,
emails: email ? [email] : [],
phonesE164: phoneResult?.e164 ? [phoneResult.e164] : [],
countryIso: country.iso ?? null,
};
return {
row,
candidate,
phoneResult,
countryIso: country.iso ?? null,
countryConfidence: country.confidence,
email,
displayName: normName.display,
};
}
interface Cluster {
/** The cluster's "lead" row (most complete + most recent). */
leadCandidate: RowCandidate;
members: RowCandidate[];
maxScore: number;
reasons: string[];
/** Pairs in this cluster that scored medium (need review). */
reviewPairs: Array<{ aSourceId: number; bSourceId: number; score: number; reasons: string[] }>;
}
function clusterByDedup(rows: RowCandidate[], opts: TransformOptions): Cluster[] {
// Use a union-find structure indexed by row id. Every pair with a
// score >= autoLink threshold gets unioned. Pairs in [needsReview,
// autoLink) accumulate onto the cluster's reviewPairs list - they're
// surfaced for human triage but not auto-merged.
const parent = new Map<string, string>();
for (const r of rows) parent.set(r.candidate.id, r.candidate.id);
const find = (id: string): string => {
let cur = id;
while (parent.get(cur) !== cur) {
const next = parent.get(cur)!;
parent.set(cur, parent.get(next)!); // path compression
cur = parent.get(cur)!;
}
return cur;
};
const union = (a: string, b: string) => {
const rootA = find(a);
const rootB = find(b);
if (rootA !== rootB) parent.set(rootA, rootB);
};
const clusterReasons = new Map<string, string[]>();
const clusterMaxScore = new Map<string, number>();
const clusterReviewPairs = new Map<string, Cluster['reviewPairs']>();
// Score every candidate against every other candidate. The find-matches
// function does its own blocking so this is cheap.
for (let i = 0; i < rows.length; i += 1) {
const left = rows[i]!;
const remainingPool = rows.slice(i + 1).map((r) => r.candidate);
if (remainingPool.length === 0) continue;
const matches = findClientMatches(left.candidate, remainingPool, {
highScore: opts.thresholds.autoLink,
mediumScore: opts.thresholds.needsReview,
});
for (const m of matches) {
if (m.score >= opts.thresholds.autoLink) {
union(left.candidate.id, m.candidate.id);
const root = find(left.candidate.id);
clusterMaxScore.set(root, Math.max(clusterMaxScore.get(root) ?? 0, m.score));
const existing = clusterReasons.get(root) ?? [];
for (const reason of m.reasons) {
if (!existing.includes(reason)) existing.push(reason);
}
clusterReasons.set(root, existing);
} else if (m.score >= opts.thresholds.needsReview) {
// Medium - track on whichever cluster `left` belongs to.
const root = find(left.candidate.id);
const list = clusterReviewPairs.get(root) ?? [];
list.push({
aSourceId: parseInt(left.candidate.id, 10),
bSourceId: parseInt(m.candidate.id, 10),
score: m.score,
reasons: m.reasons,
});
clusterReviewPairs.set(root, list);
}
}
}
// Group rows by their cluster root.
const byRoot = new Map<string, RowCandidate[]>();
for (const r of rows) {
const root = find(r.candidate.id);
const list = byRoot.get(root) ?? [];
list.push(r);
byRoot.set(root, list);
}
// Build cluster objects, choosing the most-complete row as the lead.
const clusters: Cluster[] = [];
for (const [root, members] of byRoot) {
const lead = pickLead(members);
clusters.push({
leadCandidate: lead,
members,
maxScore: clusterMaxScore.get(root) ?? 0,
reasons: clusterReasons.get(root) ?? [],
reviewPairs: clusterReviewPairs.get(root) ?? [],
});
}
return clusters;
}
function pickLead(rows: RowCandidate[]): RowCandidate {
// Pick the row with the most populated fields, breaking ties by
// recency (highest Id, since NocoDB IDs are monotonic).
return rows.reduce((best, current) => {
const bestScore = completenessScore(best);
const currentScore = completenessScore(current);
if (currentScore > bestScore) return current;
if (currentScore === bestScore && current.row.Id > best.row.Id) return current;
return best;
});
}
function completenessScore(r: RowCandidate): number {
let score = 0;
if (r.email) score += 1;
if (r.phoneResult?.e164) score += 1;
if (r.row['Address']) score += 0.5;
if (r.row['Yacht Name']) score += 0.5;
if (r.row['Source']) score += 0.25;
if (r.row['Lead Category']) score += 0.25;
if (r.row['Internal Notes']) score += 0.25;
return score;
}
function buildPlannedClient(
tempId: string,
cluster: Cluster,
opts: TransformOptions,
): PlannedClient {
const lead = cluster.leadCandidate;
// Collect distinct emails + phones from across the cluster - duplicate
// submissions often come with different contact methods we want to
// preserve as multiple rows in `client_contacts`.
const seenEmails = new Set<string>();
const seenPhones = new Set<string>();
const contacts: PlannedContact[] = [];
for (const member of cluster.members) {
if (member.email && !seenEmails.has(member.email)) {
seenEmails.add(member.email);
contacts.push({
channel: 'email',
value: member.email,
isPrimary: contacts.length === 0,
});
}
if (member.phoneResult?.e164 && !seenPhones.has(member.phoneResult.e164)) {
seenPhones.add(member.phoneResult.e164);
const isFirstPhone = !contacts.some((c) => c.channel === 'phone');
contacts.push({
channel: 'phone',
value: member.phoneResult.e164,
valueE164: member.phoneResult.e164,
valueCountry: member.phoneResult.country,
isPrimary: isFirstPhone && contacts.every((c) => !c.isPrimary || c.channel === 'email'),
flagged: member.phoneResult.flagged,
});
}
}
// Demote the email-primary if a more-completable phone exists.
// Simpler invariant: the first contact is primary unless the row
// explicitly preferred phone.
const preferredMethod = (lead.row['Contact Method Preferred'] as string | undefined)
?.toLowerCase()
?.trim();
// Address: only build if the lead row has a meaningful address text.
const rawAddress = (lead.row['Address'] as string | undefined)?.trim();
const addresses: PlannedAddress[] = [];
if (rawAddress) {
addresses.push({
streetAddress: rawAddress,
city: null,
countryIso: lead.countryIso ?? opts.defaultPhoneCountry,
countryConfidence: lead.countryConfidence ?? 'fallback',
});
}
const sourceFromRow = (lead.row['Source'] as string | undefined) ?? null;
const mappedSource = sourceFromRow ? (SOURCE_MAP[sourceFromRow] ?? 'manual') : null;
return {
tempId,
sourceIds: cluster.members.map((m) => m.row.Id),
fullName: lead.displayName,
surnameToken: lead.candidate.surnameToken ?? undefined,
countryIso: lead.countryIso,
preferredContactMethod: preferredMethod ?? null,
source: mappedSource,
contacts,
addresses,
};
}
function buildPlannedInterest(row: NocoDbRow, clientTempId: string): PlannedInterest {
const stage = (row['Sales Process Level'] as string | undefined) ?? '';
const cat = (row['Lead Category'] as string | undefined) ?? '';
const notesParts: string[] = [];
const internalNotes = row['Internal Notes'] as string | undefined;
const extraComments = row['Extra Comments'] as string | undefined;
if (internalNotes?.trim()) notesParts.push(internalNotes.trim());
if (extraComments?.trim()) notesParts.push(`Extra Comments: ${extraComments.trim()}`);
const berthSize = row['Berth Size Desired'] as string | undefined;
if (berthSize?.trim()) notesParts.push(`Berth size desired: ${berthSize.trim()}`);
return {
sourceId: row.Id,
clientTempId,
pipelineStage: STAGE_MAP[stage] ?? 'open',
leadCategory: LEAD_CATEGORY_MAP[cat] ?? null,
source: ((row['Source'] as string | undefined) ?? null) || null,
notes: notesParts.join('\n\n') || null,
berthMooringNumber: (row['Berth Number'] as string | undefined) ?? null,
yachtName: (() => {
const n = (row['Yacht Name'] as string | undefined)?.trim();
// Filter placeholder values used by sales reps for "we don't know yet".
if (!n) return null;
if (['TBC', 'Na', 'NA', 'na', 'N/A', 'TBD', 'tbd'].includes(n)) return null;
return n;
})(),
dateEoiSent: parseFlexibleDate(row['EOI Time Sent']),
dateEoiSigned: parseFlexibleDate(row['all_signed_notified_at'] ?? row['developerSignTime']),
dateDepositReceived: null, // not directly tracked in legacy schema
dateContractSent: parseFlexibleDate(row['Time LOI Sent']),
dateContractSigned: parseFlexibleDate(row['developerSignTime']),
dateLastContact: parseFlexibleDate(row['Created At'] ?? row['Date Added']),
documensoId: (row['documensoID'] as string | undefined) ?? null,
};
}
// ─── EOI document builder ───────────────────────────────────────────────────
/** Status mapping from legacy `EOI Status` SingleSelect → new
* documents.status enum. Falls back to inferring from sign-time fields
* when the legacy enum is blank or set to "Awaiting Further Details"
* (which itself does not pin a lifecycle stage - the operator was
* waiting for input from the client). */
function mapDocumentStatus(row: NocoDbRow): PlannedDocument['status'] {
const eoiStatus = (row['EOI Status'] as string | undefined)?.trim();
if (eoiStatus === 'Signed') return 'completed';
if (eoiStatus === 'Waiting for Signatures') return 'partially_signed';
// "Awaiting Further Details" or blank: fall through to sign-time
// inference. This matters because some rows have stale
// `all_signed_notified_at` from earlier signing rounds; those rows
// should NOT be auto-promoted to 'completed' if the latest enum value
// says we're still waiting on the client. We only trust sign-time
// fields when the operator hasn't explicitly set the status.
if (eoiStatus === 'Awaiting Further Details') {
if (row['EOI Time Sent']) return 'sent';
return 'draft';
}
// Sign-time fallbacks - `all_signed_notified_at` is a strong signal the
// document hit the completed lifecycle even on rows that pre-date the
// EOI Status enum.
if (row['all_signed_notified_at']) return 'completed';
if (row['developerSignTime']) return 'completed';
if (row['clientSignTime']) return 'partially_signed';
if (row['EOI Time Sent']) return 'sent';
return 'draft';
}
/**
* Emit an EOI document plan if the legacy interest row carries a
* `documensoID`. Returns null otherwise - interests without a documensoID
* never had an EOI sent, so there's nothing to migrate.
*/
function buildPlannedDocument(
row: NocoDbRow,
clientTempId: string,
clientFullName: string,
): PlannedDocument | null {
const documensoId = (row['documensoID'] as string | undefined)?.trim();
if (!documensoId) return null;
const status = mapDocumentStatus(row);
const dateSent = parseFlexibleDate(row['EOI Time Sent']);
// Build signers from the three legacy slots. Each slot has its own
// status field (sign-time present = signed). The signing/embedded URLs
// are preserved verbatim so the legacy resume-signing links still work
// for in-flight documents.
const signers: PlannedDocumentSigner[] = [];
const clientEmail = ((row['Email Address'] as string | undefined) ?? '').trim();
if (clientEmail) {
const clientSignedAt = parseFlexibleDate(
row['clientSignTime'] ?? row['client_signed_notified_at'],
);
signers.push({
signerName: clientFullName,
signerEmail: clientEmail,
signerRole: 'client',
signingOrder: 1,
status: clientSignedAt ? 'signed' : 'pending',
signedAt: clientSignedAt,
signingUrl: (row['Signature Link Client'] as string | undefined) ?? null,
embeddedUrl: (row['EmbeddedSignatureLinkClient'] as string | undefined) ?? null,
});
}
const ccLink = (row['Signature Link CC'] as string | undefined) ?? null;
const ccEmbedded = (row['EmbeddedSignatureLinkCC'] as string | undefined) ?? null;
const ccSignedAt = parseFlexibleDate(row['ccSignTime']);
if (ccLink || ccEmbedded || ccSignedAt) {
// Legacy didn't store the CC's email separately - leave a placeholder
// and let the operator update via the UI. Keeping the row preserves
// the link history.
signers.push({
signerName: 'CC (legacy migration)',
signerEmail: 'cc-unknown@migration.local',
signerRole: 'cc',
signingOrder: 2,
status: ccSignedAt ? 'signed' : 'pending',
signedAt: ccSignedAt,
signingUrl: ccLink,
embeddedUrl: ccEmbedded,
});
}
const devSignedAt = parseFlexibleDate(
row['developerSignTime'] ?? row['developer_signed_notified_at'],
);
const devLink = (row['Signature Link Developer'] as string | undefined) ?? null;
const devEmbedded = (row['EmbeddedSignatureLinkDeveloper'] as string | undefined) ?? null;
if (devLink || devEmbedded || devSignedAt) {
signers.push({
signerName: 'Developer (legacy migration)',
signerEmail: 'developer-unknown@migration.local',
signerRole: 'developer',
signingOrder: 3,
status: devSignedAt ? 'signed' : 'pending',
signedAt: devSignedAt,
signingUrl: devLink,
embeddedUrl: devEmbedded,
});
}
// Guard: an EOI document with zero signers leaves the document UI in an
// inconsistent state (status=completed but no rows in document_signers
// means the "who signed" view has nothing to show). Skip the document
// entirely rather than emit an orphaned record. This happens only when
// the legacy row carries a documensoID but lacks an Email Address AND
// has no CC/developer signature data at all - rare, but possible on
// very old rows. The flag is added so the migration report surfaces it
// for human review.
if (signers.length === 0) {
return null;
}
// Stash legacy S3 paths in notes so the reference isn't lost - copying
// attachments into the new files table is a separate workflow.
const notesParts: string[] = [];
const s3Path = (row['S3_Documenso_Path'] as string | undefined)?.trim();
const clientPath = (row['Client_EOI_Document_Path'] as string | undefined)?.trim();
if (s3Path) notesParts.push(`Legacy S3: ${s3Path}`);
if (clientPath) notesParts.push(`Legacy client copy: ${clientPath}`);
notesParts.push(`Migrated from legacy NocoDB Interests row #${row.Id}`);
return {
sourceId: row.Id,
clientTempId,
documentType: 'eoi',
title: `EOI - ${clientFullName}`,
status,
documensoId,
notes: notesParts.join('\n'),
signers,
dateSent,
};
}
// ─── Residential builder ────────────────────────────────────────────────────
function buildPlannedResidentialClient(
row: NocoDbRow,
opts: TransformOptions,
flags: MigrationFlag[],
): PlannedResidentialClient | null {
const rawName = (row['Full Name'] as string | undefined) ?? '';
const rawEmail = (row['Email Address'] as string | undefined) ?? '';
const rawPhone = (row['Phone Number'] as string | undefined) ?? '';
const rawCountry = (row['Place of Residence'] as string | undefined) ?? '';
const normName = normalizeName(rawName);
if (!normName.display) {
flags.push({
sourceTable: 'residential_interests',
sourceId: row.Id,
reason: 'residential row has no name - skipped',
});
return null;
}
const email = normalizeEmail(rawEmail);
const country = resolveCountry(rawCountry);
const phoneCountry = country.iso ?? opts.defaultPhoneCountry;
const phoneResult = normalizePhone(rawPhone, phoneCountry as CountryCode);
if (rawPhone && !phoneResult?.e164) {
flags.push({
sourceTable: 'residential_interests',
sourceId: row.Id,
reason: phoneResult?.flagged ? `phone ${phoneResult.flagged}` : 'phone unparseable',
details: { rawPhone },
});
}
if (rawEmail && !email) {
flags.push({
sourceTable: 'residential_interests',
sourceId: row.Id,
reason: 'email invalid',
details: { rawEmail },
});
}
const sourceFromRow = (row['Source'] as string | undefined) ?? null;
const mappedSource = sourceFromRow ? (SOURCE_MAP[sourceFromRow] ?? 'manual') : null;
const extraComments = (row['Extra Comments'] as string | undefined)?.trim() ?? null;
return {
sourceId: row.Id,
fullName: normName.display,
email,
phoneE164: phoneResult?.e164 ?? null,
phoneCountry: phoneResult?.country ?? null,
placeOfResidence: rawCountry.trim() || null,
placeOfResidenceCountryIso: country.iso ?? null,
source: mappedSource,
notes: extraComments,
dateFirstContact: parseFlexibleDate(
row['Time Created'] ?? row['CreatedAt'] ?? row['Created At'],
),
};
}