Treat "0" and "1" as numbers, not booleans, in custom CSV columns

The previous coercion treated "1"/"0" as booleans for parity with the
reserved `subscribed` column parser, but in custom columns those values
are far more often counts, quantities, or ids than true/false flags.
Coercing them to booleans hid the numeric segment-filter operators
(`gt`, `lt`) for fields that semantically are numbers, and miscategorised
them in `ContactService.getAvailableFields()` via `jsonb_typeof()`.

Drop "1"/"0" from the truthy/falsy keyword sets in `coerceCustomValue`.
With those entries gone the existing `NUMERIC_RE` branch picks both up
unchanged (the pattern already matches single-digit `0` and `1`), so
they land in `Contact.data` as JSON numbers. Boolean keyword coverage
remains for `true`/`false`/`yes`/`no` (case-insensitive, trimmed).

The reserved `subscribed` column parser at the top of the worker keeps
its own inline keyword set and continues to accept "1"/"0" — that
column is explicitly boolean by contract, so the asymmetry is
intentional.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Andy Grunwald
2026-05-24 10:16:34 +02:00
co-authored by Claude Opus 4.7
parent 51ac88b9f5
commit d59f4a10cd
2 changed files with 12 additions and 15 deletions
@@ -283,11 +283,11 @@ 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 => {
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 ', '0', 'no', 'NO', 'No'])('coerces %j to false', value => {
it.each(['false', 'FALSE', 'False', ' false ', 'no', 'NO', 'No'])('coerces %j to false', value => {
expect(coerceCustomValue(value)).toBe(false);
});
});
@@ -300,6 +300,8 @@ describe('coerceCustomValue', () => {
[' 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);
});
@@ -311,14 +313,6 @@ describe('coerceCustomValue', () => {
},
);
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);
});
+8 -5
View File
@@ -221,8 +221,12 @@ function isValidEmail(email: string): boolean {
return emailRegex.test(email);
}
const BOOLEAN_TRUE = new Set(['true', '1', 'yes']);
const BOOLEAN_FALSE = new Set(['false', '0', 'no']);
// `0` and `1` are intentionally absent: in custom columns they are far more
// often counts/ids/quantities than boolean flags, so they fall through to
// numeric coercion below. The reserved `subscribed` column has its own
// parser above that still accepts `1`/`0` as booleans.
const BOOLEAN_TRUE = new Set(['true', 'yes']);
const BOOLEAN_FALSE = new Set(['false', '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+)?$/;
@@ -230,9 +234,8 @@ 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 and
* numbers on custom fields the same way it already does for the reserved
* `subscribed` column. Values that match neither recogniser are returned
* unchanged.
* numbers on custom fields. Values that match neither recogniser are
* returned unchanged.
*/
export function coerceCustomValue(value: string): string | boolean | number {
const trimmed = value.trim();