From d5048e960ce328a0f10c033dfdd9cf6df66ec11a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:37:16 +0000 Subject: [PATCH 1/3] fix(contact-import): map the template's "HR" department onto the hr option The shipped contacts.csv writes "HR" in its Department column. The import path matches a picklist cell against the option VALUE exactly and the LABEL case-insensitively, so "HR" matched neither `hr` nor "Human Resources" and six of the template's fifty rows failed with invalid_option. The contact mapping now runs Department through a DEPARTMENT_SYNONYMS value map, the mechanism LEAD_SOURCE_SYNONYMS already uses; the template file is unchanged. The _shared.ts header no longer claims values match case-insensitively. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- src/sales/mappings/_shared.ts | 24 ++++++++++++++++---- src/sales/mappings/contact_import.mapping.ts | 9 ++++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/sales/mappings/_shared.ts b/src/sales/mappings/_shared.ts index 01792fc2a..b8616d6d2 100644 --- a/src/sales/mappings/_shared.ts +++ b/src/sales/mappings/_shared.ts @@ -4,11 +4,14 @@ * Shared value maps for the import mappings. * * The import path already matches a picklist cell against the field's option - * LABELS and VALUES, case-insensitively (`import-coerce.ts` → `matchOption`), - * so "Technology" / "technology" both land without any help from us. These - * maps exist only for the vocabulary a *foreign* spreadsheet uses — the words - * a Salesforce/HubSpot/Excel export writes that HotCRM has no option for - * ("SaaS", "Trade Show", "Client"). Anything not listed here falls through + * VALUE exactly and its LABEL case-insensitively (`import-coerce.ts` → + * `matchOption`), so "Technology" (label) and "technology" (value) both land + * without any help from us — but an upper-cased value does NOT: "HR" is + * neither the value `hr` nor the label "Human Resources" (#1998). These maps + * exist only for the vocabulary a spreadsheet uses that matches no option — + * the words a Salesforce/HubSpot/Excel export writes that HotCRM has no option + * for ("SaaS", "Trade Show", "Client"), and the abbreviations our own shipped + * templates write ("HR"). Anything not listed here falls through * untouched and is matched normally; an unknown value fails its row with * `invalid_option` rather than being silently dropped. * @@ -68,3 +71,14 @@ export const LEAD_SOURCE_SYNONYMS: Record = { 'Email': 'email_campaign', 'Newsletter': 'email_campaign', }; + +/** + * Spellings → `crm_contact.department` option values. The shipped + * `assets/import-templates/contacts.csv` writes eight departments; seven are + * option labels and land unaided, "HR" is the one that matches nothing (#1998). + * ⛔ The template is a customer-facing contract (#1836) — the fix lives here, + * not in the file. + */ +export const DEPARTMENT_SYNONYMS: Record = { + 'HR': 'hr', +}; diff --git a/src/sales/mappings/contact_import.mapping.ts b/src/sales/mappings/contact_import.mapping.ts index ce2e9ea32..db5837474 100644 --- a/src/sales/mappings/contact_import.mapping.ts +++ b/src/sales/mappings/contact_import.mapping.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { defineMapping } from '@objectstack/spec/data'; -import { LEAD_SOURCE_SYNONYMS } from './_shared'; +import { DEPARTMENT_SYNONYMS, LEAD_SOURCE_SYNONYMS } from './_shared'; /** * Contact import mapping — see `account_import.mapping.ts` for the shared @@ -39,7 +39,12 @@ export const ContactImportMapping = defineMapping({ { source: 'Account Name', target: 'crm_account', transform: 'lookup' }, { source: 'Title', target: 'title' }, - { source: 'Department', target: 'department' }, + { + source: 'Department', + target: 'department', + transform: 'map', + params: { valueMap: DEPARTMENT_SYNONYMS }, + }, { source: 'Email', target: 'email' }, { source: 'Phone', target: 'phone' }, { source: 'Mobile', target: 'mobile' }, From b66111aeae65ed76bcafd669a2b947f18e0e4c70 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:38:31 +0000 Subject: [PATCH 2/3] test(import): every cell of each shipped template coerces through its mapping The import-mappings suite compared each template's headers with its mapping but never ran a row through it, so six "HR" rows of contacts.csv failed at the import door while the suite stayed green. Each template's rows now go through the import door's own mapping pipeline and cell coercion (@objectstack/core applyMappingToRows + coerceRow) and the test expects no cell error. The header reader shares one quote-aware line splitter with the new row reader. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- test/import-mappings.test.ts | 56 +++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/test/import-mappings.test.ts b/test/import-mappings.test.ts index da10388a8..e935390eb 100644 --- a/test/import-mappings.test.ts +++ b/test/import-mappings.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { unknownImportMappingTargets } from '@objectstack/spec/data'; +import { applyMappingToRows, buildFieldMetaMap, coerceRow, type MappingArtifactLike } from '@objectstack/core'; import { REPO_ROOT } from './helpers/repo-root'; import stack from '../objectstack.config'; @@ -60,10 +61,15 @@ const mappingByName = (name: string): AnyRec => { const first = (v: string | string[]): string => (Array.isArray(v) ? v[0] : v); const all = (v: string | string[]): string[] => (Array.isArray(v) ? v : [v]); -/** Header row of a template CSV, honouring quoted cells. */ -const templateHeaders = (file: string): string[] => { - const text = readFileSync(join(REPO_ROOT, 'assets/import-templates', file), 'utf8'); - const line = text.split('\n')[0].replace(/\r$/, ''); +/** The non-blank lines of a template CSV (the templates carry no multi-line cell). */ +const templateLines = (file: string): string[] => + readFileSync(join(REPO_ROOT, 'assets/import-templates', file), 'utf8') + .split('\n') + .map((l) => l.replace(/\r$/, '')) + .filter((l) => l.trim() !== ''); + +/** One CSV line → its cells, honouring quoted cells. */ +const csvCells = (line: string): string[] => { const out: string[] = []; let cur = ''; let quoted = false; @@ -81,6 +87,19 @@ const templateHeaders = (file: string): string[] => { return out; }; +/** Header row of a template CSV. */ +const templateHeaders = (file: string): string[] => csvCells(templateLines(file)[0]); + +/** Data rows of a template CSV, keyed by header — the shape the import door parses a file into. */ +const templateRows = (file: string): Array> => { + const [head, ...body] = templateLines(file); + const headers = csvCells(head); + return body.map((line) => { + const cells = csvCells(line); + return Object.fromEntries(headers.map((h, i) => [h, cells[i] ?? ''])); + }); +}; + const templateDataRowCount = (file: string): number => readFileSync(join(REPO_ROOT, 'assets/import-templates', file), 'utf8') .split('\n') @@ -217,6 +236,35 @@ describe('import mappings — a template CSV imports without hand-mapping', () = expect(templateDataRowCount(template)).toBeGreaterThanOrEqual(50); }); + it.each(EXPECTED)('every cell of $template coerces through $name with no error', async ({ name, object, template }) => { + // The template is the first file a new customer imports, so every one of + // its rows must land (#1998: six `Department: "HR"` rows failed with + // `invalid_option`, because the import matches an option VALUE exactly and + // only its LABEL case-insensitively). The two steps below are the import + // door's own, from `@objectstack/core`: the mapping pipeline (value maps, + // compound-part assembly) and the per-cell coercion that produces + // `invalid_option`. Reference cells (Account Name, owner email) are + // resolved against the database at the door and are out of reach here — + // with no resolver, coercion passes them through untouched. + const m = mappingByName(name); + const def = objectByName.get(object)!; + const rows = templateRows(template); + expect(rows.length).toBe(templateDataRowCount(template)); + + const mapped = applyMappingToRows(rows, m as MappingArtifactLike, { objectSchema: def }); + if (!mapped.ok) throw new Error(`${name} refused before any row: ${mapped.code}`); + expect(mapped.rows.length).toBe(rows.length); + + const metaMap = buildFieldMetaMap(def); + const failures: string[] = []; + for (const [i, row] of mapped.rows.entries()) { + const { errors } = await coerceRow(row, metaMap, {}); + // `row` counts data rows from 1, as the import report does. + for (const e of errors) failures.push(`row ${i + 1} ${e.field} ${e.code}: ${e.message}`); + } + expect(failures, `${template} rows that fail cell coercion`).toEqual([]); + }); + it.each(EXPECTED)('$name covers every field $object requires on create', ({ name, object }) => { const m = mappingByName(name); const fields = objectByName.get(object)!.fields as Record; From f10cae689fca3f51543fdbbe3aa80db069dd7a26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:40:45 +0000 Subject: [PATCH 3/3] chore(changeset): the contact template imports all 50 rows Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- .../1998-contacts-template-hr-department.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/1998-contacts-template-hr-department.md diff --git a/.changeset/1998-contacts-template-hr-department.md b/.changeset/1998-contacts-template-hr-department.md new file mode 100644 index 000000000..579017807 --- /dev/null +++ b/.changeset/1998-contacts-template-hr-department.md @@ -0,0 +1,16 @@ +--- +'hotcrm': patch +--- + +The shipped contact import template now imports all 50 of its rows + +`assets/import-templates/contacts.csv` writes `HR` in its **Department** column. +The import matches a picklist cell against an option's code exactly and its +label case-insensitively, so `HR` matched neither the code `hr` nor the label +**Human Resources**, and six of the template's fifty rows failed with +`Department: "HR" is not a known option`. The contact import mapping +(`crm_contact_import`) now translates `HR` to **Human Resources**, the same way +it already translates foreign lead-source words such as *Trade Show*. + +The template file itself is unchanged. A dry run and a real import of it now +both report 50 rows and no errors.