diff --git a/apps/api/src/jobs/__tests__/import-processor.test.ts b/apps/api/src/jobs/__tests__/import-processor.test.ts index 05381ea..e52b140 100644 --- a/apps/api/src/jobs/__tests__/import-processor.test.ts +++ b/apps/api/src/jobs/__tests__/import-processor.test.ts @@ -292,8 +292,40 @@ describe('coerceCustomValue', () => { }); }); + describe('number coercion', () => { + it.each([ + ['42', 42], + ['-7', -7], + ['3.14', 3.14], + [' 42 ', 42], + ['0.5', 0.5], + ['-0.25', -0.25], + ])('coerces %j to %j', (value, expected) => { + expect(coerceCustomValue(value)).toBe(expected); + }); + + it.each(['01234', '+42', '.5', '42.', '1e10', 'NaN', 'Infinity', '1.2.3', '4-2'])( + 'leaves %j as a string (preserves IDs / rejects loose formats)', + value => { + expect(coerceCustomValue(value)).toBe(value); + }, + ); + + it('keeps boolean precedence: "1" stays boolean true, not number 1', () => { + expect(coerceCustomValue('1')).toBe(true); + }); + + it('keeps boolean precedence: "0" stays boolean false, not number 0', () => { + expect(coerceCustomValue('0')).toBe(false); + }); + + it('"1.0" is a number (does not match the boolean truthy set)', () => { + expect(coerceCustomValue('1.0')).toBe(1); + }); + }); + describe('passthrough', () => { - it.each(['Alice', 'true!', 'yesno', 'maybe', '42', '3.14', '01234'])('leaves %j as a string', value => { + it.each(['Alice', 'true!', 'yesno', 'maybe'])('leaves %j as a string', value => { expect(coerceCustomValue(value)).toBe(value); }); diff --git a/apps/api/src/jobs/import-processor.ts b/apps/api/src/jobs/import-processor.ts index ef38fd4..d66f540 100644 --- a/apps/api/src/jobs/import-processor.ts +++ b/apps/api/src/jobs/import-processor.ts @@ -223,16 +223,22 @@ function isValidEmail(email: string): boolean { const BOOLEAN_TRUE = new Set(['true', '1', 'yes']); const BOOLEAN_FALSE = new Set(['false', '0', 'no']); +// Strict integer-or-decimal pattern. Rejects leading zeros (preserves IDs, +// zips, phone numbers), scientific notation, `+` prefix, and `.5` / `42.`. +const NUMERIC_RE = /^-?(0|[1-9]\d*)(\.\d+)?$/; /** * Coerce a raw CSV cell to its natural JSON primitive so post-import type - * inference (ContactService.getAvailableFields) can detect booleans on custom - * fields the same way it already does for the reserved `subscribed` column. - * Values outside the recognised keyword set are returned unchanged. + * inference (ContactService.getAvailableFields) can detect booleans and + * numbers on custom fields the same way it already does for the reserved + * `subscribed` column. Values that match neither recogniser are returned + * unchanged. */ -export function coerceCustomValue(value: string): string | boolean { - const lower = value.trim().toLowerCase(); +export function coerceCustomValue(value: string): string | boolean | number { + const trimmed = value.trim(); + const lower = trimmed.toLowerCase(); if (BOOLEAN_TRUE.has(lower)) return true; if (BOOLEAN_FALSE.has(lower)) return false; + if (NUMERIC_RE.test(trimmed)) return Number(trimmed); return value; }