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>
865 lines
31 KiB
TypeScript
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'],
|
|
),
|
|
};
|
|
}
|