Coerce custom CSV column values to boolean during import

Custom CSV columns with values like "true"/"false" were stored as
strings, which made `ContactService.getAvailableFields()` report them
as string fields and lost the boolean toggle in the dashboard's
segment-filter UI. Only the reserved `subscribed` column was coerced.

Mirror the truthy keyword set already used for `subscribed`
(`true`/`1`/`yes`) and add its symmetric falsy counterpart
(`false`/`0`/`no`) for custom columns. Values outside that keyword set
are returned unchanged so names, IDs, or arbitrary strings are not
corrupted into `false`. Coercion runs over `Object.entries(customData)`
right before the `upsert()` call; no other code path needs to change
because `ContactService.upsert()` already accepts
`Record<string, unknown>` and `mergeContactData()` accepts mixed JSON
primitives. Post-import inference via `jsonb_typeof()` then classifies
the stored JSON boolean as type `boolean` automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Andy Grunwald
2026-05-24 10:16:33 +02:00
co-authored by Claude Opus 4.7
parent 32dd7bba46
commit 523bcad602
2 changed files with 44 additions and 1 deletions
@@ -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('');
});
});
});
+21 -1
View File
@@ -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;
}