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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user