From 523bcad60260f82c37955ba1a904d055105d9cec Mon Sep 17 00:00:00 2001 From: Andy Grunwald Date: Thu, 14 May 2026 11:39:19 +0200 Subject: [PATCH 1/5] 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` 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) --- .../jobs/__tests__/import-processor.test.ts | 23 +++++++++++++++++++ apps/api/src/jobs/import-processor.ts | 22 +++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) 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; +} From 51ac88b9f52715818c2d06d61a2bff560ff139c1 Mon Sep 17 00:00:00 2001 From: Andy Grunwald Date: Thu, 14 May 2026 11:40:32 +0200 Subject: [PATCH 2/5] Coerce custom CSV column values to number during import Extends the helper introduced for boolean coercion so numeric values (`42`, `3.14`) become JSON numbers in `Contact.data` instead of strings. With the right JSON primitive in place, post-import inference via `jsonb_typeof()` classifies the field as `number`, the dashboard's segment-filter UI renders a numeric input, and comparison operators (`gt`, `lt`) become available. Use a strict integer-or-decimal regex rather than `Number()` to preserve string-shaped numerics that users intend as identifiers: leading-zero IDs (`"01234"`), zip codes, phone numbers, and signed or scientific-notation forms (`"+42"`, `"1e10"`, `".5"`, `"42."`) are left as strings. Boolean coercion still takes precedence, so `"1"` and `"0"` remain booleans for consistency with how `subscribed` is parsed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../jobs/__tests__/import-processor.test.ts | 34 ++++++++++++++++++- apps/api/src/jobs/import-processor.ts | 16 ++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/apps/api/src/jobs/__tests__/import-processor.test.ts b/apps/api/src/jobs/__tests__/import-processor.test.ts index 05381ea..e52b140 100644 --- a/apps/api/src/jobs/__tests__/import-processor.test.ts +++ b/apps/api/src/jobs/__tests__/import-processor.test.ts @@ -292,8 +292,40 @@ describe('coerceCustomValue', () => { }); }); + describe('number coercion', () => { + it.each([ + ['42', 42], + ['-7', -7], + ['3.14', 3.14], + [' 42 ', 42], + ['0.5', 0.5], + ['-0.25', -0.25], + ])('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('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); + }); + }); + describe('passthrough', () => { - it.each(['Alice', 'true!', 'yesno', 'maybe', '42', '3.14', '01234'])('leaves %j as a string', value => { + it.each(['Alice', 'true!', 'yesno', 'maybe'])('leaves %j as a string', value => { expect(coerceCustomValue(value)).toBe(value); }); diff --git a/apps/api/src/jobs/import-processor.ts b/apps/api/src/jobs/import-processor.ts index ef38fd4..d66f540 100644 --- a/apps/api/src/jobs/import-processor.ts +++ b/apps/api/src/jobs/import-processor.ts @@ -223,16 +223,22 @@ function isValidEmail(email: string): boolean { const BOOLEAN_TRUE = new Set(['true', '1', 'yes']); const BOOLEAN_FALSE = new Set(['false', '0', '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+)?$/; /** * 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. + * 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. */ -export function coerceCustomValue(value: string): string | boolean { - const lower = value.trim().toLowerCase(); +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; } From d59f4a10cd017c5395b93f848b0f71ded210d214 Mon Sep 17 00:00:00 2001 From: Andy Grunwald Date: Sun, 24 May 2026 09:21:08 +0200 Subject: [PATCH 3/5] Treat "0" and "1" as numbers, not booleans, in custom CSV columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/jobs/__tests__/import-processor.test.ts | 14 ++++---------- apps/api/src/jobs/import-processor.ts | 13 ++++++++----- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/api/src/jobs/__tests__/import-processor.test.ts b/apps/api/src/jobs/__tests__/import-processor.test.ts index e52b140..2e9998f 100644 --- a/apps/api/src/jobs/__tests__/import-processor.test.ts +++ b/apps/api/src/jobs/__tests__/import-processor.test.ts @@ -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); }); diff --git a/apps/api/src/jobs/import-processor.ts b/apps/api/src/jobs/import-processor.ts index d66f540..94ff3f9 100644 --- a/apps/api/src/jobs/import-processor.ts +++ b/apps/api/src/jobs/import-processor.ts @@ -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(); From 844be42151adcc8aa87ea11065ee656797acc421 Mon Sep 17 00:00:00 2001 From: Andy Grunwald Date: Sun, 24 May 2026 11:26:52 +0200 Subject: [PATCH 4/5] Document boolean and numeric CSV value typing The preceding commits taught the import worker to coerce custom CSV column values into JSON booleans and numbers via `coerceCustomValue` in `apps/api/src/jobs/import-processor.ts`. Without a corresponding docs update, users can't predict whether a cell like `01234` lands as a string or as the number `1234`, or which segment-filter operators a field will expose after import. Add two bullets to the existing "Rules and limits" list in the contact-import guide, adjacent to the **Date columns** bullet that already documents value typing for ISO 8601 dates. The bullets mirror that style and brevity: one names the boolean keyword set and the toggle it unlocks in segment filters, the other names the numeric pattern, the `gt`/`lt` operators it unlocks, and the deliberately preserved-as-string forms (leading zeros, `+`-prefixed, scientific notation) so users keep their IDs, zip codes, and phone numbers intact. No other content is touched. Closes the documentation gap for useplunk/plunk#390. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/wiki/content/docs/guides/importing-contacts.mdx | 2 ++ 1 file changed, 2 insertions(+) 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 From 868bdee6158357194fb8d943d16f44a2539e6712 Mon Sep 17 00:00:00 2001 From: Andy Grunwald Date: Sun, 24 May 2026 16:15:57 +0200 Subject: [PATCH 5/5] import-processor: Reworked comments for `coerceCustomValue` --- apps/api/src/jobs/import-processor.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/api/src/jobs/import-processor.ts b/apps/api/src/jobs/import-processor.ts index 94ff3f9..3e05a5d 100644 --- a/apps/api/src/jobs/import-processor.ts +++ b/apps/api/src/jobs/import-processor.ts @@ -221,21 +221,23 @@ function isValidEmail(email: string): boolean { return emailRegex.test(email); } -// `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. +// 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 pattern. Rejects leading zeros (preserves IDs, -// zips, phone numbers), scientific notation, `+` prefix, and `.5` / `42.`. + +// 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+)?$/; /** - * 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. Values that match neither recogniser are + * 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();