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>
This commit is contained in:
Matt Ciaccio
2026-05-04 22:56:18 +02:00
parent 089f4a67a4
commit d62822c284
9 changed files with 938 additions and 47 deletions

View File

@@ -211,3 +211,238 @@ describe('transformSnapshot — fixture regression', () => {
expect(a.autoLinks.length).toBe(b.autoLinks.length);
});
});
// ─── EOI document derivation ───────────────────────────────────────────────
describe('transformSnapshot — EOI document derivation', () => {
/**
* A fixture row that mimics a fully-signed legacy interest with a
* Documenso ID, all three signing slots populated, and an S3 path.
* The transform should emit one PlannedDocument with three signers.
*/
function eoiFixture(
overrides: Partial<NocoDbRow> & { Id: number; documensoID: string },
): NocoDbSnapshot {
return {
fetchedAt: '2026-05-04T00:00:00.000Z',
berths: [],
residentialInterests: [],
websiteInterestSubmissions: [],
websiteContactFormSubmissions: [],
websiteBerthEoiSupplements: [],
interests: [
row({
'Full Name': 'Reza Amjad',
'Email Address': 'reza@example.com',
'Phone Number': '+15551112222',
'Sales Process Level': 'Signed EOI and NDA',
...overrides,
}),
],
};
}
it('emits no PlannedDocument when documensoID is absent', () => {
const plan = transformSnapshot({
fetchedAt: '2026-05-04T00:00:00.000Z',
berths: [],
residentialInterests: [],
websiteInterestSubmissions: [],
websiteContactFormSubmissions: [],
websiteBerthEoiSupplements: [],
interests: [
row({
Id: 100,
'Full Name': 'No EOI',
'Email Address': 'no-eoi@example.com',
'Sales Process Level': 'General Qualified Interest',
}),
],
});
expect(plan.documents).toHaveLength(0);
expect(plan.stats.outputDocuments).toBe(0);
});
it('emits one PlannedDocument per interest row with documensoID', () => {
const plan = transformSnapshot(
eoiFixture({
Id: 720,
documensoID: '107',
'EOI Status': 'Signed',
'EOI Time Sent': '2026-04-08T18:07:39.582Z',
clientSignTime: '2026-04-08T19:00:00.000Z',
developerSignTime: '2026-04-08T19:23:49.227Z',
'Signature Link Client': 'https://documenso.example/sign/abc',
S3_Documenso_Path: 'EOIs/Reza_Amjad_EOI_NDA_signed.pdf',
}),
);
expect(plan.documents).toHaveLength(1);
expect(plan.stats.outputDocuments).toBe(1);
const doc = plan.documents[0]!;
expect(doc.documensoId).toBe('107');
expect(doc.status).toBe('completed'); // EOI Status = "Signed"
expect(doc.documentType).toBe('eoi');
expect(doc.title).toBe('EOI - Reza Amjad');
expect(doc.notes).toContain('Legacy S3: EOIs/Reza_Amjad_EOI_NDA_signed.pdf');
expect(doc.notes).toContain('Migrated from legacy NocoDB Interests row #720');
const clientSigner = doc.signers.find((s) => s.signerRole === 'client');
const devSigner = doc.signers.find((s) => s.signerRole === 'developer');
expect(clientSigner?.signerEmail).toBe('reza@example.com');
expect(clientSigner?.status).toBe('signed');
expect(clientSigner?.signingUrl).toBe('https://documenso.example/sign/abc');
expect(devSigner?.status).toBe('signed');
});
it('infers status=partially_signed when EOI Status missing but client has signed', () => {
const plan = transformSnapshot(
eoiFixture({
Id: 800,
documensoID: '200',
// No EOI Status, no developer sign — only client has signed.
clientSignTime: '2026-04-01T12:00:00.000Z',
}),
);
expect(plan.documents[0]!.status).toBe('partially_signed');
});
it('infers status=sent when EOI Time Sent present but no signatures yet', () => {
const plan = transformSnapshot(
eoiFixture({
Id: 801,
documensoID: '201',
'EOI Time Sent': '2026-04-23T03:43:14.593Z',
}),
);
expect(plan.documents[0]!.status).toBe('sent');
});
it('preserves the parent interest sourceId on the document so apply can stitch', () => {
const plan = transformSnapshot(
eoiFixture({ Id: 720, documensoID: '107', 'EOI Status': 'Signed' }),
);
const doc = plan.documents[0]!;
expect(doc.sourceId).toBe(720);
expect(doc.clientTempId).toBe('client-720');
});
it('skips the CC slot when the legacy row has no CC signature data', () => {
const plan = transformSnapshot(
eoiFixture({
Id: 720,
documensoID: '107',
'EOI Status': 'Signed',
// No Signature Link CC, no ccSignTime, no Embedded CC
}),
);
const ccSigner = plan.documents[0]!.signers.find((s) => s.signerRole === 'cc');
expect(ccSigner).toBeUndefined();
});
});
// ─── Residential transform ─────────────────────────────────────────────────
describe('parseFlexibleDate format handling', () => {
// The legacy NocoDB base mixes ISO datetimes with manual DD-MM-YYYY
// entries from the Caribbean marina office. parseFlexibleDate handles
// both. Locking the disambiguation rule with explicit assertions
// because a regression here would silently shift dates by months,
// which is exactly the kind of bug nobody notices until much later.
it('parses unambiguous ISO datetimes verbatim', () => {
const plan = transformSnapshot({
fetchedAt: '2026-05-04T00:00:00.000Z',
berths: [],
residentialInterests: [],
websiteInterestSubmissions: [],
websiteContactFormSubmissions: [],
websiteBerthEoiSupplements: [],
interests: [
row({
Id: 1,
'Full Name': 'ISO Test',
'Email Address': 'iso@example.com',
'EOI Time Sent': '2026-04-08T18:07:39.582Z',
}),
],
});
expect(plan.interests[0]!.dateEoiSent).toBe('2026-04-08T18:07:39.582Z');
});
it('parses 01-02-2025 as Feb 1 (DD-MM-YYYY), not Jan 2 (MM-DD-YYYY)', () => {
// The Caribbean office uses day-first dates. Feb 1 = 01-02-2025.
// If a regression flips this to MM-DD parsing, the migration would
// mis-stamp every manually-entered date by ~30 days.
const plan = transformSnapshot({
fetchedAt: '2026-05-04T00:00:00.000Z',
berths: [],
residentialInterests: [],
websiteInterestSubmissions: [],
websiteContactFormSubmissions: [],
websiteBerthEoiSupplements: [],
interests: [
row({
Id: 2,
'Full Name': 'DDMM Test',
'Email Address': 'ddmm@example.com',
'Time LOI Sent': '01-02-2025',
}),
],
});
const iso = plan.interests[0]!.dateContractSent;
expect(iso?.startsWith('2025-02-01')).toBe(true);
});
});
describe('transformSnapshot — residential leads', () => {
it('produces one PlannedResidentialClient per source row', () => {
const plan = transformSnapshot({
fetchedAt: '2026-05-04T00:00:00.000Z',
berths: [],
websiteInterestSubmissions: [],
websiteContactFormSubmissions: [],
websiteBerthEoiSupplements: [],
interests: [],
residentialInterests: [
row({
Id: 6,
'Full Name': 'FABIO GOMEZ',
'Email Address': 'fabio@example.com',
'Phone Number': '+19143371482',
'Place of Residence': 'USA',
}),
row({
Id: 7,
'Full Name': 'James Wilkinson',
'Email Address': 'jcw@example.com',
'Phone Number': '+12485684256',
}),
],
});
expect(plan.residentialClients).toHaveLength(2);
expect(plan.stats.outputResidentialClients).toBe(2);
const fabio = plan.residentialClients.find((c) => c.sourceId === 6);
expect(fabio?.fullName).toBe('Fabio Gomez');
expect(fabio?.email).toBe('fabio@example.com');
expect(fabio?.phoneE164).toBe('+19143371482');
expect(fabio?.placeOfResidenceCountryIso).toBe('US');
});
it('skips residential rows with no name', () => {
const plan = transformSnapshot({
fetchedAt: '2026-05-04T00:00:00.000Z',
berths: [],
websiteInterestSubmissions: [],
websiteContactFormSubmissions: [],
websiteBerthEoiSupplements: [],
interests: [],
residentialInterests: [row({ Id: 100, 'Full Name': '', 'Email Address': 'x@y.com' })],
});
expect(plan.residentialClients).toHaveLength(0);
expect(plan.flags.some((f) => f.sourceId === 100)).toBe(true);
});
});