@marlinjai/contacts-core 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/{chunk-FPZSYMBL.js → chunk-5LG5GEWU.js} +12 -2
- package/dist/chunk-5LG5GEWU.js.map +1 -0
- package/dist/{chunk-OHFPW5YM.js → chunk-B2NV5TFC.js} +32 -1
- package/dist/{chunk-OHFPW5YM.js.map → chunk-B2NV5TFC.js.map} +1 -1
- package/dist/import-cli.js +170 -5
- package/dist/import-cli.js.map +1 -1
- package/dist/index.cjs +222 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +78 -4
- package/dist/index.d.ts +78 -4
- package/dist/index.js +205 -4
- package/dist/index.js.map +1 -1
- package/dist/migrate-cli.js +1 -1
- package/dist/model.cjs +13 -1
- package/dist/model.cjs.map +1 -1
- package/dist/model.d.cts +21 -2
- package/dist/model.d.ts +21 -2
- package/dist/model.js +5 -1
- package/package.json +44 -15
- package/dist/chunk-FPZSYMBL.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
All notable changes to `@marlinjai/contacts-core` are documented here. The format is based on
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
|
|
6
|
+
## [0.2.0] - 2026-10-09
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Custom fields per company: `createField`, `listFields`, `archiveField`. Field types are the data
|
|
11
|
+
table's type names (text, number, date, boolean, select, multi_select, url); the value checks live
|
|
12
|
+
in this package and refuse rather than coerce. Archiving keeps stored values and refuses new ones.
|
|
13
|
+
- Contacts take `customFields` on create and update (a null value removes that field), refused when
|
|
14
|
+
a key has no definition or its definition is archived.
|
|
15
|
+
- `preferredContact` on every contact: `email`, `phone`, `post` or `none`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Migration `0002_contact_fields` adds the two columns and the definitions table. Additive only.
|
|
20
|
+
|
|
6
21
|
## [0.1.0] - 2026-10-08
|
|
7
22
|
|
|
8
23
|
### Added
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// src/model.ts
|
|
2
|
+
var PREFERRED_CONTACT = ["email", "phone", "post", "none"];
|
|
3
|
+
var FIELD_TYPES = ["text", "number", "date", "boolean", "select", "multi_select", "url"];
|
|
2
4
|
var ContactError = class extends Error {
|
|
3
5
|
code;
|
|
4
6
|
/** For `duplicate`: the contact that already carries this identity. For `stale`: the current record. */
|
|
@@ -77,9 +79,15 @@ function normalizeContactInput(input) {
|
|
|
77
79
|
postalCode: optional(input.postalCode, "postalCode", 20),
|
|
78
80
|
city: optional(input.city, "city", SHORT_FIELD_MAX),
|
|
79
81
|
country,
|
|
80
|
-
vatId: optional(input.vatId, "vatId", 40)
|
|
82
|
+
vatId: optional(input.vatId, "vatId", 40),
|
|
83
|
+
preferredContact: preferredContact(input.preferredContact)
|
|
81
84
|
};
|
|
82
85
|
}
|
|
86
|
+
function preferredContact(value) {
|
|
87
|
+
if (value === null || value === void 0) return null;
|
|
88
|
+
if (PREFERRED_CONTACT.includes(value)) return value;
|
|
89
|
+
throw new ContactError("invalid_field", { field: "preferredContact" });
|
|
90
|
+
}
|
|
83
91
|
function contactIdentityKey(kind, name, companyOrRole = "") {
|
|
84
92
|
const n = collapse(name).toLocaleLowerCase("de-DE");
|
|
85
93
|
if (kind === "organization") return n;
|
|
@@ -93,6 +101,8 @@ function formatCustomerNumber(n, width = 4) {
|
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
export {
|
|
104
|
+
PREFERRED_CONTACT,
|
|
105
|
+
FIELD_TYPES,
|
|
96
106
|
ContactError,
|
|
97
107
|
CONTACT_NAME_MAX,
|
|
98
108
|
ORGANIZATION_NAME_MAX,
|
|
@@ -105,4 +115,4 @@ export {
|
|
|
105
115
|
formatGuest,
|
|
106
116
|
formatCustomerNumber
|
|
107
117
|
};
|
|
108
|
-
//# sourceMappingURL=chunk-
|
|
118
|
+
//# sourceMappingURL=chunk-5LG5GEWU.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/model.ts"],"sourcesContent":["/**\n * The contact model: types, validation and the identity key.\n *\n * This file is pure (no database, no Node built-ins) and safe to import\n * anywhere, including browser code, through `@marlinjai/contacts-core/model`.\n */\n\nexport type ContactKind = 'person' | 'organization';\n\n/** How a contact prefers to be reached. Null when none was recorded. */\nexport const PREFERRED_CONTACT = ['email', 'phone', 'post', 'none'] as const;\nexport type PreferredContact = (typeof PREFERRED_CONTACT)[number];\n\n/** The field types of a custom field: the data table's column type names (see fields.ts). */\nexport const FIELD_TYPES = ['text', 'number', 'date', 'boolean', 'select', 'multi_select', 'url'] as const;\nexport type FieldType = (typeof FIELD_TYPES)[number];\n\n/** One stored value of a custom field. Shapes are checked by fields.ts against the field's type. */\nexport type FieldValue = string | number | boolean | string[];\nexport type FieldValues = Record<string, FieldValue>;\n\nexport interface Contact {\n /** Stable id. Never reused, never derived from the name. */\n id: string;\n kind: ContactKind;\n name: string;\n /** Persons only: company or role as printed next to the name. Empty string when unknown. */\n companyOrRole: string;\n /** Persons only: the organization record this person belongs to, when linked. */\n organizationId: string | null;\n email: string | null;\n phone: string | null;\n note: string | null;\n /** Organizations only. */\n legalForm: string | null;\n addressLine1: string | null;\n addressLine2: string | null;\n postalCode: string | null;\n city: string | null;\n /** Two-letter country code, upper case. */\n country: string | null;\n vatId: string | null;\n /** Unique per company once assigned; never reused. */\n customerNumber: number | null;\n preferredContact: PreferredContact | null;\n /** The company's custom fields set on this contact, keyed by field key. */\n customFields: FieldValues;\n /** Rises by one with every change; pass it back as `expectedVersion` to refuse a stale save. */\n version: number;\n archived: boolean;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface ContactInput {\n kind?: ContactKind;\n name: string;\n companyOrRole?: string | null;\n organizationId?: string | null;\n email?: string | null;\n phone?: string | null;\n note?: string | null;\n legalForm?: string | null;\n addressLine1?: string | null;\n addressLine2?: string | null;\n postalCode?: string | null;\n city?: string | null;\n country?: string | null;\n vatId?: string | null;\n preferredContact?: string | null;\n /**\n * Field values to set, keyed by field key. A null value removes that field's value.\n * Keys not given are left as they are. Checked against the company's definitions.\n */\n customFields?: Record<string, unknown>;\n}\n\nexport type ContactErrorCode =\n | 'invalid_name'\n | 'invalid_field'\n | 'too_long'\n | 'duplicate'\n | 'not_found'\n | 'stale'\n | 'invalid_organization'\n | 'customer_number_conflict'\n | 'merge_kind_mismatch'\n | 'unknown_field'\n | 'field_archived'\n | 'invalid_value'\n | 'duplicate_field'\n | 'field_not_found';\n\nexport class ContactError extends Error {\n readonly code: ContactErrorCode;\n /** For `duplicate`: the contact that already carries this identity. For `stale`: the current record. */\n readonly existing?: Contact;\n /** For `invalid_field` and `too_long`: the offending field. */\n readonly field?: string;\n constructor(code: ContactErrorCode, options: { existing?: Contact; field?: string } = {}) {\n super(options.field ? `${code}: ${options.field}` : code);\n this.name = 'ContactError';\n this.code = code;\n this.existing = options.existing;\n this.field = options.field;\n }\n}\n\nexport const CONTACT_NAME_MAX = 120;\nexport const ORGANIZATION_NAME_MAX = 160;\nexport const CONTACT_COMPANY_MAX = 160;\nexport const CONTACT_NOTE_MAX = 500;\nexport const CONTACT_EMAIL_MAX = 254;\nconst SHORT_FIELD_MAX = 160;\n\nfunction collapse(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\nfunction optional(value: string | null | undefined, field: string, max: number): string | null {\n const text = collapse(String(value ?? ''));\n if (!text) return null;\n if (text.length > max) throw new ContactError('too_long', { field });\n return text;\n}\n\nexport interface NormalizedContact {\n kind: ContactKind;\n name: string;\n companyOrRole: string;\n organizationId: string | null;\n email: string | null;\n phone: string | null;\n note: string | null;\n legalForm: string | null;\n addressLine1: string | null;\n addressLine2: string | null;\n postalCode: string | null;\n city: string | null;\n country: string | null;\n vatId: string | null;\n preferredContact: PreferredContact | null;\n}\n\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nexport function isContactId(value: unknown): value is string {\n return typeof value === 'string' && UUID.test(value);\n}\n\n/**\n * Trim and validate. Throws ContactError('invalid_name' | 'invalid_field' | 'too_long').\n * Fields that do not belong to the kind (a legal form on a person, a company\n * text on an organization) are refused, never silently dropped.\n */\nexport function normalizeContactInput(input: ContactInput): NormalizedContact {\n const kind: ContactKind = input.kind ?? 'person';\n if (kind !== 'person' && kind !== 'organization') throw new ContactError('invalid_field', { field: 'kind' });\n\n const name = collapse(String(input.name ?? ''));\n if (!name) throw new ContactError('invalid_name');\n if (name.length > (kind === 'person' ? CONTACT_NAME_MAX : ORGANIZATION_NAME_MAX)) {\n throw new ContactError('too_long', { field: 'name' });\n }\n\n const companyOrRole = collapse(String(input.companyOrRole ?? ''));\n if (companyOrRole.length > CONTACT_COMPANY_MAX) throw new ContactError('too_long', { field: 'companyOrRole' });\n const organizationId = input.organizationId ?? null;\n const legalForm = optional(input.legalForm, 'legalForm', SHORT_FIELD_MAX);\n\n if (kind === 'organization') {\n if (companyOrRole) throw new ContactError('invalid_field', { field: 'companyOrRole' });\n if (organizationId) throw new ContactError('invalid_field', { field: 'organizationId' });\n } else {\n if (legalForm) throw new ContactError('invalid_field', { field: 'legalForm' });\n if (organizationId !== null && !isContactId(organizationId)) {\n throw new ContactError('invalid_field', { field: 'organizationId' });\n }\n }\n\n const emailRaw = optional(input.email, 'email', CONTACT_EMAIL_MAX);\n const email = emailRaw === null ? null : emailRaw.toLowerCase();\n if (email !== null && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) {\n throw new ContactError('invalid_field', { field: 'email' });\n }\n\n const countryRaw = optional(input.country, 'country', SHORT_FIELD_MAX);\n const country = countryRaw === null ? null : countryRaw.toUpperCase();\n if (country !== null && !/^[A-Z]{2}$/.test(country)) throw new ContactError('invalid_field', { field: 'country' });\n\n const noteRaw = String(input.note ?? '').trim();\n if (noteRaw.length > CONTACT_NOTE_MAX) throw new ContactError('too_long', { field: 'note' });\n\n return {\n kind,\n name,\n companyOrRole,\n organizationId,\n email,\n phone: optional(input.phone, 'phone', 40),\n note: noteRaw || null,\n legalForm,\n addressLine1: optional(input.addressLine1, 'addressLine1', SHORT_FIELD_MAX),\n addressLine2: optional(input.addressLine2, 'addressLine2', SHORT_FIELD_MAX),\n postalCode: optional(input.postalCode, 'postalCode', 20),\n city: optional(input.city, 'city', SHORT_FIELD_MAX),\n country,\n vatId: optional(input.vatId, 'vatId', 40),\n preferredContact: preferredContact(input.preferredContact),\n };\n}\n\nfunction preferredContact(value: string | null | undefined): PreferredContact | null {\n if (value === null || value === undefined) return null;\n if ((PREFERRED_CONTACT as readonly string[]).includes(value)) return value as PreferredContact;\n throw new ContactError('invalid_field', { field: 'preferredContact' });\n}\n\n/**\n * The key two contacts of one company and one kind share when they are \"the\n * same\" for the duplicate check: the name for an organization, name plus\n * company or role for a person. Case and repeated whitespace do not count.\n */\nexport function contactIdentityKey(kind: ContactKind, name: string, companyOrRole = ''): string {\n const n = collapse(name).toLocaleLowerCase('de-DE');\n if (kind === 'organization') return n;\n // Unit separator: cannot appear in collapsed text, and (unlike NUL) Postgres text can hold it.\n return `${n}\\u001f${collapse(companyOrRole).toLocaleLowerCase('de-DE')}`;\n}\n\n/** How a person is printed: \"Name (Company)\" or just \"Name\". */\nexport function formatGuest(name: string, company: string): string {\n return company ? `${name} (${company})` : name;\n}\n\n/** \"0025\" for 25. Padding is presentation; the stored number is an integer. */\nexport function formatCustomerNumber(n: number, width = 4): string {\n return String(n).padStart(width, '0');\n}\n"],"mappings":"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","names":[]}
|
|
@@ -56,6 +56,37 @@ CREATE TABLE tenant_counters (
|
|
|
56
56
|
tenant_id text PRIMARY KEY CHECK (char_length(tenant_id) BETWEEN 1 AND 64),
|
|
57
57
|
next_customer_number integer NOT NULL DEFAULT 1 CHECK (next_customer_number > 0)
|
|
58
58
|
);
|
|
59
|
+
`
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "0002_contact_fields",
|
|
63
|
+
sql: `
|
|
64
|
+
-- A preferred way to be contacted. Null means none was recorded.
|
|
65
|
+
ALTER TABLE contacts
|
|
66
|
+
ADD COLUMN preferred_contact text CHECK (preferred_contact IN ('email', 'phone', 'post', 'none'));
|
|
67
|
+
|
|
68
|
+
-- Values of the company's custom fields, keyed by field key. Checked against the
|
|
69
|
+
-- definitions by the package on every write, so the column itself only insists on an object.
|
|
70
|
+
ALTER TABLE contacts
|
|
71
|
+
ADD COLUMN custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(custom_fields) = 'object');
|
|
72
|
+
|
|
73
|
+
-- One definition per field and company. Archived definitions stay, so the values
|
|
74
|
+
-- stored under them keep their meaning; the package refuses new values for them.
|
|
75
|
+
CREATE TABLE contact_field_definitions (
|
|
76
|
+
tenant_id text NOT NULL CHECK (char_length(tenant_id) BETWEEN 1 AND 64),
|
|
77
|
+
id uuid NOT NULL,
|
|
78
|
+
key text NOT NULL CHECK (key ~ '^[a-z][a-z0-9_]{1,39}$'),
|
|
79
|
+
label text NOT NULL CHECK (char_length(label) BETWEEN 1 AND 80),
|
|
80
|
+
type text NOT NULL CHECK (type IN ('text', 'number', 'date', 'boolean', 'select', 'multi_select', 'url')),
|
|
81
|
+
-- Choices for select and multi_select; absent for every other type.
|
|
82
|
+
options jsonb CHECK (options IS NULL OR jsonb_typeof(options) = 'array'),
|
|
83
|
+
archived_at timestamptz,
|
|
84
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
85
|
+
PRIMARY KEY (tenant_id, id),
|
|
86
|
+
CONSTRAINT contact_field_definitions_key UNIQUE (tenant_id, key),
|
|
87
|
+
CONSTRAINT contact_field_definitions_options
|
|
88
|
+
CHECK ((type IN ('select', 'multi_select')) = (options IS NOT NULL))
|
|
89
|
+
);
|
|
59
90
|
`
|
|
60
91
|
}
|
|
61
92
|
];
|
|
@@ -151,4 +182,4 @@ export {
|
|
|
151
182
|
ContactsLayoutError,
|
|
152
183
|
missingMigrations
|
|
153
184
|
};
|
|
154
|
-
//# sourceMappingURL=chunk-
|
|
185
|
+
//# sourceMappingURL=chunk-B2NV5TFC.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/migrate.ts","../src/migrations/index.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport type { Sql } from 'postgres';\nimport { MIGRATIONS, type MigrationSource } from './migrations/index.js';\n\n/**\n * The migrations runner (the mail service's runner, with the migrations in\n * code). Each app that uses the contacts database runs it at start, before it\n * serves.\n *\n * - Each migration applies in its own transaction together with its\n * `_migrations` row, so a failing one leaves nothing half-applied and the\n * next run resumes from it.\n * - Every transaction takes the same advisory lock first and re-checks what is\n * applied, so two apps starting at once apply each migration exactly once.\n * - An applied migration whose text has since changed stops the run: applied\n * migrations are history, and a change belongs in a new one.\n * - A migration the database has but this build does not know is what an app\n * on an older version of the package sees. Migrations are additive, so the\n * older code keeps working: it is reported, not fatal.\n */\n\n/** Arbitrary, fixed: \"lumitra contacts migrations\". */\nconst LOCK_KEY = 7_382_014_552;\nconst NAME_PATTERN = /^(\\d{4})_[a-z0-9_]+$/;\n\nexport type Migration = MigrationSource & { checksum: string };\n\nexport class MigrationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'MigrationError';\n }\n}\n\nexport function loadMigrations(sources: readonly MigrationSource[] = MIGRATIONS): Migration[] {\n const seen = new Set<string>();\n const out: Migration[] = [];\n for (const m of sources) {\n const match = NAME_PATTERN.exec(m.name);\n if (!match) throw new MigrationError(`${m.name}: migrations are named NNNN_lowercase_name`);\n if (seen.has(match[1]!)) throw new MigrationError(`${m.name}: two migrations share the number ${match[1]}`);\n seen.add(match[1]!);\n out.push({ ...m, checksum: createHash('sha256').update(m.sql).digest('hex') });\n }\n return out.sort((a, b) => a.name.localeCompare(b.name));\n}\n\nexport type MigrateResult = { applied: string[]; alreadyApplied: number; unknown: string[] };\n\nexport async function migrate(\n sql: Sql,\n options: { migrations?: readonly MigrationSource[]; log?: (line: string) => void } = {},\n): Promise<MigrateResult> {\n const log = options.log ?? ((line: string) => console.log(`[contacts-migrate] ${line}`));\n const migrations = loadMigrations(options.migrations);\n\n await sql.begin(async (tx) => {\n await tx`SELECT pg_advisory_xact_lock(${LOCK_KEY})`;\n await tx`\n CREATE TABLE IF NOT EXISTS _migrations (\n name text PRIMARY KEY,\n checksum text NOT NULL,\n applied_at timestamptz NOT NULL DEFAULT now()\n )`;\n });\n\n const recorded = await sql<{ name: string; checksum: string }[]>`SELECT name, checksum FROM _migrations`;\n const byName = new Map(recorded.map((r) => [r.name, r.checksum]));\n for (const m of migrations) {\n const checksum = byName.get(m.name);\n if (checksum !== undefined && checksum !== m.checksum) {\n throw new MigrationError(\n `${m.name} was changed after it was applied. Applied migrations are never edited; put the change in a new one.`,\n );\n }\n }\n const known = new Set(migrations.map((m) => m.name));\n const unknown = recorded.map((r) => r.name).filter((n) => !known.has(n));\n for (const name of unknown) {\n log(`note: ${name} is applied in the database but not part of this build (another app runs a newer package)`);\n }\n\n const applied: string[] = [];\n for (const m of migrations) {\n const didApply = await sql.begin(async (tx) => {\n await tx`SELECT pg_advisory_xact_lock(${LOCK_KEY})`;\n const done = await tx`SELECT 1 FROM _migrations WHERE name = ${m.name}`;\n if (done.length > 0) return false;\n await tx.unsafe(m.sql);\n await tx`INSERT INTO _migrations (name, checksum) VALUES (${m.name}, ${m.checksum})`;\n return true;\n });\n if (didApply) {\n applied.push(m.name);\n log(`applied ${m.name}`);\n }\n }\n const alreadyApplied = migrations.length - applied.length;\n log(applied.length === 0 ? `up to date (${alreadyApplied} applied)` : `done: ${applied.length} new, ${alreadyApplied} before`);\n return { applied, alreadyApplied, unknown };\n}\n\nexport class ContactsLayoutError extends Error {\n readonly missing: string[];\n constructor(missing: string[]) {\n super(\n `The contacts database is behind this version of @marlinjai/contacts-core: missing ${missing.join(', ')}. ` +\n 'Run contacts-migrate (or migrate()) before using it.',\n );\n this.name = 'ContactsLayoutError';\n this.missing = missing;\n }\n}\n\n/**\n * The names of the migrations this build needs and the database lacks. Empty\n * means usable. A database that is AHEAD (it has migrations this build does not\n * know) is fine, because migrations are additive.\n */\nexport async function missingMigrations(\n sql: Sql,\n sources: readonly MigrationSource[] = MIGRATIONS,\n): Promise<string[]> {\n const needed = loadMigrations(sources).map((m) => m.name);\n const table = await sql<{ present: boolean }[]>`SELECT to_regclass('_migrations') IS NOT NULL AS present`;\n if (!table[0]?.present) return needed;\n const rows = await sql<{ name: string }[]>`SELECT name FROM _migrations`;\n const have = new Set(rows.map((r) => r.name));\n return needed.filter((n) => !have.has(n));\n}\n","/**\n * The table changes of the contacts database, in order.\n *\n * They live in code, not in .sql files, so they travel inside the bundle of\n * whatever app imports this package (a Next.js standalone build copies no\n * loose files out of node_modules).\n *\n * RULES\n * - Additive only: add a table, a column or an index. Never rename or drop in\n * the same release that stops using something; several apps on different\n * versions of this package share this database. A removal ships only after\n * every app runs a version that no longer reads the thing.\n * - An applied migration is history. Never edit one; add a new one. The\n * runner refuses to continue when the text of an applied migration changed.\n */\n\nexport interface MigrationSource {\n /** `NNNN_lowercase_name`, unique number. */\n name: string;\n sql: string;\n}\n\nexport const MIGRATIONS: readonly MigrationSource[] = [\n {\n name: '0001_contacts',\n sql: `\nCREATE TABLE contacts (\n id uuid PRIMARY KEY,\n -- The auth-brain tenant (the company). Text, because auth-brain ids are\n -- opaque to this package.\n tenant_id text NOT NULL CHECK (char_length(tenant_id) BETWEEN 1 AND 64),\n kind text NOT NULL CHECK (kind IN ('person', 'organization')),\n name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 160),\n -- Persons only. Empty string, not NULL, so it can take part in the identity key.\n company_or_role text NOT NULL DEFAULT '' CHECK (char_length(company_or_role) <= 160),\n organization_id uuid,\n email text,\n phone text,\n note text,\n legal_form text,\n address_line1 text,\n address_line2 text,\n postal_code text,\n city text,\n country text CHECK (country IS NULL OR country ~ '^[A-Z]{2}$'),\n vat_id text,\n customer_number integer CHECK (customer_number IS NULL OR customer_number > 0),\n -- Written by the package (contactIdentityKey); the duplicate check.\n identity_key text NOT NULL,\n version integer NOT NULL DEFAULT 1,\n archived_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT contacts_person_fields CHECK (kind = 'person' OR (company_or_role = '' AND organization_id IS NULL)),\n CONSTRAINT contacts_organization_fields CHECK (kind = 'organization' OR legal_form IS NULL),\n -- Target of the composite foreign key below: a link can only point inside the same company.\n CONSTRAINT contacts_tenant_id_id UNIQUE (tenant_id, id),\n CONSTRAINT contacts_organization_fk FOREIGN KEY (tenant_id, organization_id)\n REFERENCES contacts (tenant_id, id) ON DELETE SET NULL (organization_id)\n);\n\nCREATE UNIQUE INDEX contacts_identity ON contacts (tenant_id, kind, identity_key);\nCREATE UNIQUE INDEX contacts_customer_number ON contacts (tenant_id, customer_number)\n WHERE customer_number IS NOT NULL;\nCREATE INDEX contacts_list ON contacts (tenant_id, name);\nCREATE INDEX contacts_organization ON contacts (tenant_id, organization_id)\n WHERE organization_id IS NOT NULL;\n\n-- One counter per company. A number is taken with a single UPDATE ... RETURNING\n-- in the transaction that sets it on the contact, so it is never issued twice.\nCREATE TABLE tenant_counters (\n tenant_id text PRIMARY KEY CHECK (char_length(tenant_id) BETWEEN 1 AND 64),\n next_customer_number integer NOT NULL DEFAULT 1 CHECK (next_customer_number > 0)\n);\n`,\n },\n];\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/migrate.ts","../src/migrations/index.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport type { Sql } from 'postgres';\nimport { MIGRATIONS, type MigrationSource } from './migrations/index.js';\n\n/**\n * The migrations runner (the mail service's runner, with the migrations in\n * code). Each app that uses the contacts database runs it at start, before it\n * serves.\n *\n * - Each migration applies in its own transaction together with its\n * `_migrations` row, so a failing one leaves nothing half-applied and the\n * next run resumes from it.\n * - Every transaction takes the same advisory lock first and re-checks what is\n * applied, so two apps starting at once apply each migration exactly once.\n * - An applied migration whose text has since changed stops the run: applied\n * migrations are history, and a change belongs in a new one.\n * - A migration the database has but this build does not know is what an app\n * on an older version of the package sees. Migrations are additive, so the\n * older code keeps working: it is reported, not fatal.\n */\n\n/** Arbitrary, fixed: \"lumitra contacts migrations\". */\nconst LOCK_KEY = 7_382_014_552;\nconst NAME_PATTERN = /^(\\d{4})_[a-z0-9_]+$/;\n\nexport type Migration = MigrationSource & { checksum: string };\n\nexport class MigrationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'MigrationError';\n }\n}\n\nexport function loadMigrations(sources: readonly MigrationSource[] = MIGRATIONS): Migration[] {\n const seen = new Set<string>();\n const out: Migration[] = [];\n for (const m of sources) {\n const match = NAME_PATTERN.exec(m.name);\n if (!match) throw new MigrationError(`${m.name}: migrations are named NNNN_lowercase_name`);\n if (seen.has(match[1]!)) throw new MigrationError(`${m.name}: two migrations share the number ${match[1]}`);\n seen.add(match[1]!);\n out.push({ ...m, checksum: createHash('sha256').update(m.sql).digest('hex') });\n }\n return out.sort((a, b) => a.name.localeCompare(b.name));\n}\n\nexport type MigrateResult = { applied: string[]; alreadyApplied: number; unknown: string[] };\n\nexport async function migrate(\n sql: Sql,\n options: { migrations?: readonly MigrationSource[]; log?: (line: string) => void } = {},\n): Promise<MigrateResult> {\n const log = options.log ?? ((line: string) => console.log(`[contacts-migrate] ${line}`));\n const migrations = loadMigrations(options.migrations);\n\n await sql.begin(async (tx) => {\n await tx`SELECT pg_advisory_xact_lock(${LOCK_KEY})`;\n await tx`\n CREATE TABLE IF NOT EXISTS _migrations (\n name text PRIMARY KEY,\n checksum text NOT NULL,\n applied_at timestamptz NOT NULL DEFAULT now()\n )`;\n });\n\n const recorded = await sql<{ name: string; checksum: string }[]>`SELECT name, checksum FROM _migrations`;\n const byName = new Map(recorded.map((r) => [r.name, r.checksum]));\n for (const m of migrations) {\n const checksum = byName.get(m.name);\n if (checksum !== undefined && checksum !== m.checksum) {\n throw new MigrationError(\n `${m.name} was changed after it was applied. Applied migrations are never edited; put the change in a new one.`,\n );\n }\n }\n const known = new Set(migrations.map((m) => m.name));\n const unknown = recorded.map((r) => r.name).filter((n) => !known.has(n));\n for (const name of unknown) {\n log(`note: ${name} is applied in the database but not part of this build (another app runs a newer package)`);\n }\n\n const applied: string[] = [];\n for (const m of migrations) {\n const didApply = await sql.begin(async (tx) => {\n await tx`SELECT pg_advisory_xact_lock(${LOCK_KEY})`;\n const done = await tx`SELECT 1 FROM _migrations WHERE name = ${m.name}`;\n if (done.length > 0) return false;\n await tx.unsafe(m.sql);\n await tx`INSERT INTO _migrations (name, checksum) VALUES (${m.name}, ${m.checksum})`;\n return true;\n });\n if (didApply) {\n applied.push(m.name);\n log(`applied ${m.name}`);\n }\n }\n const alreadyApplied = migrations.length - applied.length;\n log(applied.length === 0 ? `up to date (${alreadyApplied} applied)` : `done: ${applied.length} new, ${alreadyApplied} before`);\n return { applied, alreadyApplied, unknown };\n}\n\nexport class ContactsLayoutError extends Error {\n readonly missing: string[];\n constructor(missing: string[]) {\n super(\n `The contacts database is behind this version of @marlinjai/contacts-core: missing ${missing.join(', ')}. ` +\n 'Run contacts-migrate (or migrate()) before using it.',\n );\n this.name = 'ContactsLayoutError';\n this.missing = missing;\n }\n}\n\n/**\n * The names of the migrations this build needs and the database lacks. Empty\n * means usable. A database that is AHEAD (it has migrations this build does not\n * know) is fine, because migrations are additive.\n */\nexport async function missingMigrations(\n sql: Sql,\n sources: readonly MigrationSource[] = MIGRATIONS,\n): Promise<string[]> {\n const needed = loadMigrations(sources).map((m) => m.name);\n const table = await sql<{ present: boolean }[]>`SELECT to_regclass('_migrations') IS NOT NULL AS present`;\n if (!table[0]?.present) return needed;\n const rows = await sql<{ name: string }[]>`SELECT name FROM _migrations`;\n const have = new Set(rows.map((r) => r.name));\n return needed.filter((n) => !have.has(n));\n}\n","/**\n * The table changes of the contacts database, in order.\n *\n * They live in code, not in .sql files, so they travel inside the bundle of\n * whatever app imports this package (a Next.js standalone build copies no\n * loose files out of node_modules).\n *\n * RULES\n * - Additive only: add a table, a column or an index. Never rename or drop in\n * the same release that stops using something; several apps on different\n * versions of this package share this database. A removal ships only after\n * every app runs a version that no longer reads the thing.\n * - An applied migration is history. Never edit one; add a new one. The\n * runner refuses to continue when the text of an applied migration changed.\n */\n\nexport interface MigrationSource {\n /** `NNNN_lowercase_name`, unique number. */\n name: string;\n sql: string;\n}\n\nexport const MIGRATIONS: readonly MigrationSource[] = [\n {\n name: '0001_contacts',\n sql: `\nCREATE TABLE contacts (\n id uuid PRIMARY KEY,\n -- The auth-brain tenant (the company). Text, because auth-brain ids are\n -- opaque to this package.\n tenant_id text NOT NULL CHECK (char_length(tenant_id) BETWEEN 1 AND 64),\n kind text NOT NULL CHECK (kind IN ('person', 'organization')),\n name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 160),\n -- Persons only. Empty string, not NULL, so it can take part in the identity key.\n company_or_role text NOT NULL DEFAULT '' CHECK (char_length(company_or_role) <= 160),\n organization_id uuid,\n email text,\n phone text,\n note text,\n legal_form text,\n address_line1 text,\n address_line2 text,\n postal_code text,\n city text,\n country text CHECK (country IS NULL OR country ~ '^[A-Z]{2}$'),\n vat_id text,\n customer_number integer CHECK (customer_number IS NULL OR customer_number > 0),\n -- Written by the package (contactIdentityKey); the duplicate check.\n identity_key text NOT NULL,\n version integer NOT NULL DEFAULT 1,\n archived_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT contacts_person_fields CHECK (kind = 'person' OR (company_or_role = '' AND organization_id IS NULL)),\n CONSTRAINT contacts_organization_fields CHECK (kind = 'organization' OR legal_form IS NULL),\n -- Target of the composite foreign key below: a link can only point inside the same company.\n CONSTRAINT contacts_tenant_id_id UNIQUE (tenant_id, id),\n CONSTRAINT contacts_organization_fk FOREIGN KEY (tenant_id, organization_id)\n REFERENCES contacts (tenant_id, id) ON DELETE SET NULL (organization_id)\n);\n\nCREATE UNIQUE INDEX contacts_identity ON contacts (tenant_id, kind, identity_key);\nCREATE UNIQUE INDEX contacts_customer_number ON contacts (tenant_id, customer_number)\n WHERE customer_number IS NOT NULL;\nCREATE INDEX contacts_list ON contacts (tenant_id, name);\nCREATE INDEX contacts_organization ON contacts (tenant_id, organization_id)\n WHERE organization_id IS NOT NULL;\n\n-- One counter per company. A number is taken with a single UPDATE ... RETURNING\n-- in the transaction that sets it on the contact, so it is never issued twice.\nCREATE TABLE tenant_counters (\n tenant_id text PRIMARY KEY CHECK (char_length(tenant_id) BETWEEN 1 AND 64),\n next_customer_number integer NOT NULL DEFAULT 1 CHECK (next_customer_number > 0)\n);\n`,\n },\n {\n name: '0002_contact_fields',\n sql: `\n-- A preferred way to be contacted. Null means none was recorded.\nALTER TABLE contacts\n ADD COLUMN preferred_contact text CHECK (preferred_contact IN ('email', 'phone', 'post', 'none'));\n\n-- Values of the company's custom fields, keyed by field key. Checked against the\n-- definitions by the package on every write, so the column itself only insists on an object.\nALTER TABLE contacts\n ADD COLUMN custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(custom_fields) = 'object');\n\n-- One definition per field and company. Archived definitions stay, so the values\n-- stored under them keep their meaning; the package refuses new values for them.\nCREATE TABLE contact_field_definitions (\n tenant_id text NOT NULL CHECK (char_length(tenant_id) BETWEEN 1 AND 64),\n id uuid NOT NULL,\n key text NOT NULL CHECK (key ~ '^[a-z][a-z0-9_]{1,39}$'),\n label text NOT NULL CHECK (char_length(label) BETWEEN 1 AND 80),\n type text NOT NULL CHECK (type IN ('text', 'number', 'date', 'boolean', 'select', 'multi_select', 'url')),\n -- Choices for select and multi_select; absent for every other type.\n options jsonb CHECK (options IS NULL OR jsonb_typeof(options) = 'array'),\n archived_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n PRIMARY KEY (tenant_id, id),\n CONSTRAINT contact_field_definitions_key UNIQUE (tenant_id, key),\n CONSTRAINT contact_field_definitions_options\n CHECK ((type IN ('select', 'multi_select')) = (options IS NOT NULL))\n);\n`,\n },\n];\n"],"mappings":";;;AAAA,SAAS,kBAAkB;;;ACsBpB,IAAM,aAAyC;AAAA,EACpD;AAAA,IACE,MAAM;AAAA,IACN,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAkDP;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA4BP;AACF;;;ADrFA,IAAM,WAAW;AACjB,IAAM,eAAe;AAId,IAAM,iBAAN,cAA6B,MAAM;AAAA,EACxC,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAEO,SAAS,eAAe,UAAsC,YAAyB;AAC5F,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,MAAmB,CAAC;AAC1B,aAAW,KAAK,SAAS;AACvB,UAAM,QAAQ,aAAa,KAAK,EAAE,IAAI;AACtC,QAAI,CAAC,MAAO,OAAM,IAAI,eAAe,GAAG,EAAE,IAAI,4CAA4C;AAC1F,QAAI,KAAK,IAAI,MAAM,CAAC,CAAE,EAAG,OAAM,IAAI,eAAe,GAAG,EAAE,IAAI,qCAAqC,MAAM,CAAC,CAAC,EAAE;AAC1G,SAAK,IAAI,MAAM,CAAC,CAAE;AAClB,QAAI,KAAK,EAAE,GAAG,GAAG,UAAU,WAAW,QAAQ,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,KAAK,EAAE,CAAC;AAAA,EAC/E;AACA,SAAO,IAAI,KAAK,CAAC,GAAG,MAAM,EAAE,KAAK,cAAc,EAAE,IAAI,CAAC;AACxD;AAIA,eAAsB,QACpB,KACA,UAAqF,CAAC,GAC9D;AACxB,QAAM,MAAM,QAAQ,QAAQ,CAAC,SAAiB,QAAQ,IAAI,sBAAsB,IAAI,EAAE;AACtF,QAAM,aAAa,eAAe,QAAQ,UAAU;AAEpD,QAAM,IAAI,MAAM,OAAO,OAAO;AAC5B,UAAM,kCAAkC,QAAQ;AAChD,UAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMR,CAAC;AAED,QAAM,WAAW,MAAM;AACvB,QAAM,SAAS,IAAI,IAAI,SAAS,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC;AAChE,aAAW,KAAK,YAAY;AAC1B,UAAM,WAAW,OAAO,IAAI,EAAE,IAAI;AAClC,QAAI,aAAa,UAAa,aAAa,EAAE,UAAU;AACrD,YAAM,IAAI;AAAA,QACR,GAAG,EAAE,IAAI;AAAA,MACX;AAAA,IACF;AAAA,EACF;AACA,QAAM,QAAQ,IAAI,IAAI,WAAW,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC;AACnD,QAAM,UAAU,SAAS,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;AACvE,aAAW,QAAQ,SAAS;AAC1B,QAAI,SAAS,IAAI,2FAA2F;AAAA,EAC9G;AAEA,QAAM,UAAoB,CAAC;AAC3B,aAAW,KAAK,YAAY;AAC1B,UAAM,WAAW,MAAM,IAAI,MAAM,OAAO,OAAO;AAC7C,YAAM,kCAAkC,QAAQ;AAChD,YAAM,OAAO,MAAM,4CAA4C,EAAE,IAAI;AACrE,UAAI,KAAK,SAAS,EAAG,QAAO;AAC5B,YAAM,GAAG,OAAO,EAAE,GAAG;AACrB,YAAM,sDAAsD,EAAE,IAAI,KAAK,EAAE,QAAQ;AACjF,aAAO;AAAA,IACT,CAAC;AACD,QAAI,UAAU;AACZ,cAAQ,KAAK,EAAE,IAAI;AACnB,UAAI,WAAW,EAAE,IAAI,EAAE;AAAA,IACzB;AAAA,EACF;AACA,QAAM,iBAAiB,WAAW,SAAS,QAAQ;AACnD,MAAI,QAAQ,WAAW,IAAI,eAAe,cAAc,cAAc,SAAS,QAAQ,MAAM,SAAS,cAAc,SAAS;AAC7H,SAAO,EAAE,SAAS,gBAAgB,QAAQ;AAC5C;AAEO,IAAM,sBAAN,cAAkC,MAAM;AAAA,EACpC;AAAA,EACT,YAAY,SAAmB;AAC7B;AAAA,MACE,qFAAqF,QAAQ,KAAK,IAAI,CAAC;AAAA,IAEzG;AACA,SAAK,OAAO;AACZ,SAAK,UAAU;AAAA,EACjB;AACF;AAOA,eAAsB,kBACpB,KACA,UAAsC,YACnB;AACnB,QAAM,SAAS,eAAe,OAAO,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI;AACxD,QAAM,QAAQ,MAAM;AACpB,MAAI,CAAC,MAAM,CAAC,GAAG,QAAS,QAAO;AAC/B,QAAM,OAAO,MAAM;AACnB,QAAM,OAAO,IAAI,IAAI,KAAK,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC;AAC5C,SAAO,OAAO,OAAO,CAAC,MAAM,CAAC,KAAK,IAAI,CAAC,CAAC;AAC1C;","names":[]}
|
package/dist/import-cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
ContactsLayoutError,
|
|
4
4
|
missingMigrations
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-B2NV5TFC.js";
|
|
6
6
|
|
|
7
7
|
// src/cli/import.ts
|
|
8
8
|
import { readFile } from "fs/promises";
|
|
@@ -11,6 +11,8 @@ import { readFile } from "fs/promises";
|
|
|
11
11
|
import { sql } from "kysely";
|
|
12
12
|
|
|
13
13
|
// src/model.ts
|
|
14
|
+
var PREFERRED_CONTACT = ["email", "phone", "post", "none"];
|
|
15
|
+
var FIELD_TYPES = ["text", "number", "date", "boolean", "select", "multi_select", "url"];
|
|
14
16
|
var ContactError = class extends Error {
|
|
15
17
|
code;
|
|
16
18
|
/** For `duplicate`: the contact that already carries this identity. For `stale`: the current record. */
|
|
@@ -89,15 +91,123 @@ function normalizeContactInput(input) {
|
|
|
89
91
|
postalCode: optional(input.postalCode, "postalCode", 20),
|
|
90
92
|
city: optional(input.city, "city", SHORT_FIELD_MAX),
|
|
91
93
|
country,
|
|
92
|
-
vatId: optional(input.vatId, "vatId", 40)
|
|
94
|
+
vatId: optional(input.vatId, "vatId", 40),
|
|
95
|
+
preferredContact: preferredContact(input.preferredContact)
|
|
93
96
|
};
|
|
94
97
|
}
|
|
98
|
+
function preferredContact(value) {
|
|
99
|
+
if (value === null || value === void 0) return null;
|
|
100
|
+
if (PREFERRED_CONTACT.includes(value)) return value;
|
|
101
|
+
throw new ContactError("invalid_field", { field: "preferredContact" });
|
|
102
|
+
}
|
|
95
103
|
function contactIdentityKey(kind, name, companyOrRole = "") {
|
|
96
104
|
const n = collapse(name).toLocaleLowerCase("de-DE");
|
|
97
105
|
if (kind === "organization") return n;
|
|
98
106
|
return `${n}${collapse(companyOrRole).toLocaleLowerCase("de-DE")}`;
|
|
99
107
|
}
|
|
100
108
|
|
|
109
|
+
// src/fields.ts
|
|
110
|
+
var FIELD_KEY = /^[a-z][a-z0-9_]{1,39}$/;
|
|
111
|
+
var FIELD_LABEL_MAX = 80;
|
|
112
|
+
var FIELD_OPTION_MAX = 80;
|
|
113
|
+
var FIELD_OPTIONS_MAX = 100;
|
|
114
|
+
var FIELD_TEXT_MAX = 2e3;
|
|
115
|
+
var FIELD_URL_MAX = 2048;
|
|
116
|
+
var collapse2 = (text) => text.replace(/\s+/g, " ").trim();
|
|
117
|
+
function normalizeFieldDefinition(input) {
|
|
118
|
+
const key = String(input.key ?? "");
|
|
119
|
+
if (!FIELD_KEY.test(key)) throw new ContactError("invalid_field", { field: "key" });
|
|
120
|
+
const label = collapse2(String(input.label ?? ""));
|
|
121
|
+
if (!label) throw new ContactError("invalid_field", { field: "label" });
|
|
122
|
+
if (label.length > FIELD_LABEL_MAX) throw new ContactError("too_long", { field: "label" });
|
|
123
|
+
if (!FIELD_TYPES.includes(input.type)) {
|
|
124
|
+
throw new ContactError("invalid_field", { field: "type" });
|
|
125
|
+
}
|
|
126
|
+
const type = input.type;
|
|
127
|
+
const choosable = type === "select" || type === "multi_select";
|
|
128
|
+
if (!choosable) {
|
|
129
|
+
if (input.options !== void 0 && input.options !== null) {
|
|
130
|
+
throw new ContactError("invalid_field", { field: "options" });
|
|
131
|
+
}
|
|
132
|
+
return { key, label, type, options: null };
|
|
133
|
+
}
|
|
134
|
+
if (!Array.isArray(input.options) || input.options.length === 0 || input.options.length > FIELD_OPTIONS_MAX) {
|
|
135
|
+
throw new ContactError("invalid_field", { field: "options" });
|
|
136
|
+
}
|
|
137
|
+
const options = input.options.map((o) => collapse2(String(o)));
|
|
138
|
+
for (const option of options) {
|
|
139
|
+
if (!option) throw new ContactError("invalid_field", { field: "options" });
|
|
140
|
+
if (option.length > FIELD_OPTION_MAX) throw new ContactError("too_long", { field: "options" });
|
|
141
|
+
}
|
|
142
|
+
if (new Set(options).size !== options.length) throw new ContactError("invalid_field", { field: "options" });
|
|
143
|
+
return { key, label, type, options };
|
|
144
|
+
}
|
|
145
|
+
var ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
146
|
+
function checkFieldValue(def, value) {
|
|
147
|
+
const refuse = () => new ContactError("invalid_value", { field: def.key });
|
|
148
|
+
switch (def.type) {
|
|
149
|
+
case "text": {
|
|
150
|
+
if (typeof value !== "string" || value.length === 0 || value.length > FIELD_TEXT_MAX) throw refuse();
|
|
151
|
+
if (value !== value.trim()) throw refuse();
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
case "number": {
|
|
155
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw refuse();
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
case "date": {
|
|
159
|
+
if (typeof value !== "string") throw refuse();
|
|
160
|
+
const match = ISO_DAY.exec(value);
|
|
161
|
+
if (!match) throw refuse();
|
|
162
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
163
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
164
|
+
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) throw refuse();
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
case "boolean": {
|
|
168
|
+
if (typeof value !== "boolean") throw refuse();
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
case "select": {
|
|
172
|
+
if (typeof value !== "string" || !def.options?.includes(value)) throw refuse();
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
case "multi_select": {
|
|
176
|
+
if (!Array.isArray(value) || value.length === 0) throw refuse();
|
|
177
|
+
if (!value.every((v) => typeof v === "string" && def.options?.includes(v))) throw refuse();
|
|
178
|
+
if (new Set(value).size !== value.length) throw refuse();
|
|
179
|
+
return [...value];
|
|
180
|
+
}
|
|
181
|
+
case "url": {
|
|
182
|
+
if (typeof value !== "string" || value.length > FIELD_URL_MAX) throw refuse();
|
|
183
|
+
let parsed;
|
|
184
|
+
try {
|
|
185
|
+
parsed = new URL(value);
|
|
186
|
+
} catch {
|
|
187
|
+
throw refuse();
|
|
188
|
+
}
|
|
189
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw refuse();
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function applyCustomFieldPatch(current, patch, definitions) {
|
|
195
|
+
const byKey = new Map(definitions.map((d) => [d.key, d]));
|
|
196
|
+
const next = { ...current };
|
|
197
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
198
|
+
if (value === void 0) continue;
|
|
199
|
+
const def = byKey.get(key);
|
|
200
|
+
if (!def) throw new ContactError("unknown_field", { field: key });
|
|
201
|
+
if (value === null) {
|
|
202
|
+
delete next[key];
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (def.archived) throw new ContactError("field_archived", { field: key });
|
|
206
|
+
next[key] = checkFieldValue(def, value);
|
|
207
|
+
}
|
|
208
|
+
return next;
|
|
209
|
+
}
|
|
210
|
+
|
|
101
211
|
// src/uuid.ts
|
|
102
212
|
import { randomBytes } from "crypto";
|
|
103
213
|
function uuidv7(now = Date.now()) {
|
|
@@ -133,13 +243,25 @@ function toContact(row) {
|
|
|
133
243
|
country: row.country,
|
|
134
244
|
vatId: row.vat_id,
|
|
135
245
|
customerNumber: row.customer_number,
|
|
246
|
+
preferredContact: row.preferred_contact,
|
|
247
|
+
customFields: row.custom_fields ?? {},
|
|
136
248
|
version: row.version,
|
|
137
249
|
archived: row.archived_at !== null,
|
|
138
250
|
createdAt: row.created_at,
|
|
139
251
|
updatedAt: row.updated_at
|
|
140
252
|
};
|
|
141
253
|
}
|
|
142
|
-
function
|
|
254
|
+
function toDefinition(row) {
|
|
255
|
+
return {
|
|
256
|
+
key: row.key,
|
|
257
|
+
label: row.label,
|
|
258
|
+
type: row.type,
|
|
259
|
+
options: row.options,
|
|
260
|
+
archived: row.archived_at !== null,
|
|
261
|
+
createdAt: row.created_at
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function columns(data, customFields) {
|
|
143
265
|
return {
|
|
144
266
|
name: data.name,
|
|
145
267
|
company_or_role: data.companyOrRole,
|
|
@@ -154,6 +276,8 @@ function columns(data) {
|
|
|
154
276
|
city: data.city,
|
|
155
277
|
country: data.country,
|
|
156
278
|
vat_id: data.vatId,
|
|
279
|
+
preferred_contact: data.preferredContact,
|
|
280
|
+
custom_fields: sql`${JSON.stringify(customFields)}::text::jsonb`,
|
|
157
281
|
identity_key: contactIdentityKey(data.kind, data.name, data.companyOrRole)
|
|
158
282
|
};
|
|
159
283
|
}
|
|
@@ -174,6 +298,10 @@ function contactsFor(handle, tenantId) {
|
|
|
174
298
|
}
|
|
175
299
|
const { db } = handle;
|
|
176
300
|
const own = (trx = db) => trx.selectFrom("contacts").selectAll().where("tenant_id", "=", tenantId);
|
|
301
|
+
async function loadDefinitions(trx = db) {
|
|
302
|
+
const rows = await trx.selectFrom("contact_field_definitions").selectAll().where("tenant_id", "=", tenantId).orderBy("created_at").orderBy("key").execute();
|
|
303
|
+
return rows.map(toDefinition);
|
|
304
|
+
}
|
|
177
305
|
async function findByIdentity(kind, name, companyOrRole, exceptId) {
|
|
178
306
|
let q = own().where("kind", "=", kind).where("identity_key", "=", contactIdentityKey(kind, name, companyOrRole));
|
|
179
307
|
if (exceptId) q = q.where("id", "!=", exceptId);
|
|
@@ -245,7 +373,8 @@ function contactsFor(handle, tenantId) {
|
|
|
245
373
|
if (!isContactId(id)) throw new ContactError("invalid_field", { field: "id" });
|
|
246
374
|
await handle.ready();
|
|
247
375
|
await assertOrganization(data.organizationId);
|
|
248
|
-
const
|
|
376
|
+
const customFields = input.customFields ? applyCustomFieldPatch({}, input.customFields, await loadDefinitions()) : {};
|
|
377
|
+
const inserted = await db.insertInto("contacts").values({ id, tenant_id: tenantId, kind: data.kind, ...columns(data, customFields) }).onConflict((oc) => oc.columns(["tenant_id", "kind", "identity_key"]).doNothing()).returningAll().executeTakeFirst();
|
|
249
378
|
if (!inserted) {
|
|
250
379
|
const existing = await findByIdentity(data.kind, data.name, data.companyOrRole);
|
|
251
380
|
throw new ContactError("duplicate", { existing: existing ?? void 0 });
|
|
@@ -269,7 +398,9 @@ function contactsFor(handle, tenantId) {
|
|
|
269
398
|
const org = await own(trx).where("id", "=", data.organizationId).executeTakeFirst();
|
|
270
399
|
if (!org || org.kind !== "organization") throw new ContactError("invalid_organization");
|
|
271
400
|
}
|
|
272
|
-
const
|
|
401
|
+
const currentFields = row.custom_fields ?? {};
|
|
402
|
+
const customFields = input.customFields ? applyCustomFieldPatch(currentFields, input.customFields, await loadDefinitions(trx)) : currentFields;
|
|
403
|
+
const updated = await trx.updateTable("contacts").set({ ...columns(data, customFields), version: sql`version + 1`, updated_at: /* @__PURE__ */ new Date() }).where("tenant_id", "=", tenantId).where("id", "=", id).returningAll().executeTakeFirstOrThrow();
|
|
273
404
|
return toContact(updated);
|
|
274
405
|
});
|
|
275
406
|
} catch (e) {
|
|
@@ -280,6 +411,40 @@ function contactsFor(handle, tenantId) {
|
|
|
280
411
|
throw e;
|
|
281
412
|
}
|
|
282
413
|
},
|
|
414
|
+
async createField(input) {
|
|
415
|
+
await handle.ready();
|
|
416
|
+
const data = normalizeFieldDefinition(input);
|
|
417
|
+
try {
|
|
418
|
+
const row = await db.insertInto("contact_field_definitions").values({
|
|
419
|
+
tenant_id: tenantId,
|
|
420
|
+
id: uuidv7(),
|
|
421
|
+
key: data.key,
|
|
422
|
+
label: data.label,
|
|
423
|
+
type: data.type,
|
|
424
|
+
options: data.options === null ? null : sql`${JSON.stringify(data.options)}::text::jsonb`
|
|
425
|
+
}).returningAll().executeTakeFirstOrThrow();
|
|
426
|
+
return toDefinition(row);
|
|
427
|
+
} catch (e) {
|
|
428
|
+
if (e.constraint_name === "contact_field_definitions_key") {
|
|
429
|
+
throw new ContactError("duplicate_field", { field: data.key });
|
|
430
|
+
}
|
|
431
|
+
throw e;
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
async listFields(options = {}) {
|
|
435
|
+
await handle.ready();
|
|
436
|
+
const definitions = await loadDefinitions();
|
|
437
|
+
return options.includeArchived ? definitions : definitions.filter((d) => !d.archived);
|
|
438
|
+
},
|
|
439
|
+
/** Archives a field: its stored values stay, and new values under it are refused. Repeating it changes nothing. */
|
|
440
|
+
async archiveField(key) {
|
|
441
|
+
await handle.ready();
|
|
442
|
+
const row = await db.selectFrom("contact_field_definitions").selectAll().where("tenant_id", "=", tenantId).where("key", "=", key).executeTakeFirst();
|
|
443
|
+
if (!row) throw new ContactError("field_not_found", { field: key });
|
|
444
|
+
if (row.archived_at !== null) return toDefinition(row);
|
|
445
|
+
const updated = await db.updateTable("contact_field_definitions").set({ archived_at: /* @__PURE__ */ new Date() }).where("tenant_id", "=", tenantId).where("key", "=", key).returningAll().executeTakeFirstOrThrow();
|
|
446
|
+
return toDefinition(updated);
|
|
447
|
+
},
|
|
283
448
|
archive: (id) => setArchived(id, true),
|
|
284
449
|
restore: (id) => setArchived(id, false),
|
|
285
450
|
async merge(loserId, winnerId) {
|