Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/1998-contacts-template-hr-department.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 19 additions & 5 deletions src/sales/mappings/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -68,3 +71,14 @@ export const LEAD_SOURCE_SYNONYMS: Record<string, string> = {
'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<string, string> = {
'HR': 'hr',
};
9 changes: 7 additions & 2 deletions src/sales/mappings/contact_import.mapping.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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' },
Expand Down
56 changes: 52 additions & 4 deletions test/import-mappings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand All @@ -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<Record<string, string>> => {
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')
Expand Down Expand Up @@ -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<string, AnyRec>;
Expand Down
Loading