diff --git a/apps/api/src/jobs/__tests__/import-processor.test.ts b/apps/api/src/jobs/__tests__/import-processor.test.ts index b246699..2e9998f 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,51 @@ describe('Contact Import - Subscription Status Preservation', () => { }); }); }); + +describe('coerceCustomValue', () => { + describe('boolean coercion', () => { + it.each(['true', 'TRUE', 'True', ' true ', 'yes', 'YES', 'Yes'])('coerces %j to true', value => { + expect(coerceCustomValue(value)).toBe(true); + }); + + it.each(['false', 'FALSE', 'False', ' false ', 'no', 'NO', 'No'])('coerces %j to false', value => { + expect(coerceCustomValue(value)).toBe(false); + }); + }); + + describe('number coercion', () => { + it.each([ + ['42', 42], + ['-7', -7], + ['3.14', 3.14], + [' 42 ', 42], + ['0.5', 0.5], + ['-0.25', -0.25], + ['0', 0], + ['1', 1], + ])('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('"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'])('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..3e05a5d 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,30 @@ function isValidEmail(email: string): boolean { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } + +// Values considered as boolean during import. +// Numbers (0, 1) are intentionally absent. +const BOOLEAN_TRUE = new Set(['true', 'yes']); +const BOOLEAN_FALSE = new Set(['false', 'no']); + +// Strict integer-or-decimal number detection pattern. +// Valid: 0, 42, -42, 3.14 +// Rejected: 007, +42, 1.2.3, 1e5 +const NUMERIC_RE = /^-?(0|[1-9]\d*)(\.\d+)?$/; + +/** + * Coerces a raw string into its most natural primitive type: `boolean`, + * `number`, or `string`. Values that match neither are + * returned unchanged. + * + * @param value The raw string to coerce. + * @returns The coerced value as `boolean`, `number`, or `string`. + */ +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; +} diff --git a/apps/wiki/content/docs/guides/importing-contacts.mdx b/apps/wiki/content/docs/guides/importing-contacts.mdx index 2950fd6..82b9f07 100644 --- a/apps/wiki/content/docs/guides/importing-contacts.mdx +++ b/apps/wiki/content/docs/guides/importing-contacts.mdx @@ -37,6 +37,8 @@ In this example, every imported contact ends up with `data.firstName`, `data.pla - **Email column**: must be present and valid. Rows with missing or invalid emails are reported back as errors. - **Reserved column names**: `id`, `subscribed`, `createdAt`, `updatedAt`, and the auto-generated URL variables (`unsubscribeUrl`, etc.) are silently filtered out. Don't include them as columns. - **Date columns**: use ISO 8601 (`2026-05-06T12:00:00Z`) so they're typed as dates and become usable with `within` / `olderThan` segment operators. +- **Boolean columns**: `true`, `false`, `yes`, `no` (case-insensitive) are stored as booleans and get the boolean toggle in segment filters. +- **Numeric columns**: plain integers and decimals (`42`, `3.14`) are stored as numbers and become usable with `gt` / `lt` segment operators. Leading-zero values (`01234`), `+`-prefixed numbers, and scientific notation stay strings so IDs, zip codes, and phone numbers aren't corrupted. - **Existing contacts**: if a row's email matches an existing contact, the import **updates** the contact (merging the CSV's columns into `data`). It doesn't create a duplicate or overwrite the whole record. ## Importing your CSV