diff --git a/apps/api/src/jobs/__tests__/import-processor.test.ts b/apps/api/src/jobs/__tests__/import-processor.test.ts index b246699..05381ea 100644 --- a/apps/api/src/jobs/__tests__/import-processor.test.ts +++ b/apps/api/src/jobs/__tests__/import-processor.test.ts @@ -1,6 +1,7 @@ import {beforeEach, describe, expect, it} from 'vitest'; import {factories, getPrismaClient} from '../../../../../test/helpers'; import {ContactService} from '../../services/ContactService.js'; +import {coerceCustomValue} from '../import-processor.js'; /** * Tests for Contact Import Processor - Subscription Status Preservation @@ -279,3 +280,25 @@ describe('Contact Import - Subscription Status Preservation', () => { }); }); }); + +describe('coerceCustomValue', () => { + describe('boolean coercion', () => { + it.each(['true', 'TRUE', 'True', ' true ', '1', 'yes', 'YES', 'Yes'])('coerces %j to true', value => { + expect(coerceCustomValue(value)).toBe(true); + }); + + it.each(['false', 'FALSE', 'False', ' false ', '0', 'no', 'NO', 'No'])('coerces %j to false', value => { + expect(coerceCustomValue(value)).toBe(false); + }); + }); + + describe('passthrough', () => { + it.each(['Alice', 'true!', 'yesno', 'maybe', '42', '3.14', '01234'])('leaves %j as a string', value => { + expect(coerceCustomValue(value)).toBe(value); + }); + + it('leaves empty string as empty string', () => { + expect(coerceCustomValue('')).toBe(''); + }); + }); +}); diff --git a/apps/api/src/jobs/import-processor.ts b/apps/api/src/jobs/import-processor.ts index f98ccea..ef38fd4 100644 --- a/apps/api/src/jobs/import-processor.ts +++ b/apps/api/src/jobs/import-processor.ts @@ -123,7 +123,11 @@ export function createImportWorker() { // Extract custom data (all fields except email and subscribed) const {email: _, subscribed: __, ...customData} = record; - const data = Object.keys(customData).length > 0 ? customData : undefined; + const customEntries = Object.entries(customData); + const data = + customEntries.length > 0 + ? Object.fromEntries(customEntries.map(([k, v]) => [k, coerceCustomValue(v)])) + : undefined; // Check if contact exists before upserting const existingContact = await ContactService.findByEmail(projectId, email); @@ -216,3 +220,19 @@ function isValidEmail(email: string): boolean { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } + +const BOOLEAN_TRUE = new Set(['true', '1', 'yes']); +const BOOLEAN_FALSE = new Set(['false', '0', 'no']); + +/** + * 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. + */ +export function coerceCustomValue(value: string): string | boolean { + const lower = value.trim().toLowerCase(); + if (BOOLEAN_TRUE.has(lower)) return true; + if (BOOLEAN_FALSE.has(lower)) return false; + return value; +}