Merge pull request #391 from andygrunwald/andygrunwald/csv-import-coerce-custom-field-types
fix: coerce boolean and numeric values in custom CSV columns
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import {beforeEach, describe, expect, it} from 'vitest';
|
import {beforeEach, describe, expect, it} from 'vitest';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
import {ContactService} from '../../services/ContactService.js';
|
import {ContactService} from '../../services/ContactService.js';
|
||||||
|
import {coerceCustomValue} from '../import-processor.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for Contact Import Processor - Subscription Status Preservation
|
* 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -123,7 +123,11 @@ export function createImportWorker() {
|
|||||||
|
|
||||||
// Extract custom data (all fields except email and subscribed)
|
// Extract custom data (all fields except email and subscribed)
|
||||||
const {email: _, subscribed: __, ...customData} = record;
|
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
|
// Check if contact exists before upserting
|
||||||
const existingContact = await ContactService.findByEmail(projectId, email);
|
const existingContact = await ContactService.findByEmail(projectId, email);
|
||||||
@@ -216,3 +220,30 @@ function isValidEmail(email: string): boolean {
|
|||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
return emailRegex.test(email);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Importing your CSV
|
||||||
|
|||||||
Reference in New Issue
Block a user