/** * 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; } 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 = { '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 = { General: 'general_interest', 'Friends and Family': 'general_interest', }; const SOURCE_MAP: Record = { 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 = {}, ): 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(); 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(); const clusterMaxScore = new Map(); const clusterReviewPairs = new Map(); // 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(); 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(); const seenPhones = new Set(); 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'], ), }; }