@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/dist/index.cjs CHANGED
@@ -37,10 +37,20 @@ __export(src_exports, {
37
37
  ContactError: () => ContactError,
38
38
  ContactsLayoutError: () => ContactsLayoutError,
39
39
  CsvError: () => CsvError,
40
+ FIELD_KEY: () => FIELD_KEY,
41
+ FIELD_LABEL_MAX: () => FIELD_LABEL_MAX,
42
+ FIELD_OPTIONS_MAX: () => FIELD_OPTIONS_MAX,
43
+ FIELD_OPTION_MAX: () => FIELD_OPTION_MAX,
44
+ FIELD_TEXT_MAX: () => FIELD_TEXT_MAX,
45
+ FIELD_TYPES: () => FIELD_TYPES,
46
+ FIELD_URL_MAX: () => FIELD_URL_MAX,
40
47
  IMPORT_COLUMNS: () => IMPORT_COLUMNS,
41
48
  MIGRATIONS: () => MIGRATIONS,
42
49
  MigrationError: () => MigrationError,
43
50
  ORGANIZATION_NAME_MAX: () => ORGANIZATION_NAME_MAX,
51
+ PREFERRED_CONTACT: () => PREFERRED_CONTACT,
52
+ applyCustomFieldPatch: () => applyCustomFieldPatch,
53
+ checkFieldValue: () => checkFieldValue,
44
54
  contactIdentityKey: () => contactIdentityKey,
45
55
  contactsFor: () => contactsFor,
46
56
  createContactsDb: () => createContactsDb,
@@ -52,12 +62,15 @@ __export(src_exports, {
52
62
  migrate: () => migrate,
53
63
  missingMigrations: () => missingMigrations,
54
64
  normalizeContactInput: () => normalizeContactInput,
65
+ normalizeFieldDefinition: () => normalizeFieldDefinition,
55
66
  parseCsv: () => parseCsv,
56
67
  uuidv7: () => uuidv7
57
68
  });
58
69
  module.exports = __toCommonJS(src_exports);
59
70
 
60
71
  // src/model.ts
72
+ var PREFERRED_CONTACT = ["email", "phone", "post", "none"];
73
+ var FIELD_TYPES = ["text", "number", "date", "boolean", "select", "multi_select", "url"];
61
74
  var ContactError = class extends Error {
62
75
  code;
63
76
  /** For `duplicate`: the contact that already carries this identity. For `stale`: the current record. */
@@ -136,9 +149,15 @@ function normalizeContactInput(input) {
136
149
  postalCode: optional(input.postalCode, "postalCode", 20),
137
150
  city: optional(input.city, "city", SHORT_FIELD_MAX),
138
151
  country,
139
- vatId: optional(input.vatId, "vatId", 40)
152
+ vatId: optional(input.vatId, "vatId", 40),
153
+ preferredContact: preferredContact(input.preferredContact)
140
154
  };
141
155
  }
156
+ function preferredContact(value) {
157
+ if (value === null || value === void 0) return null;
158
+ if (PREFERRED_CONTACT.includes(value)) return value;
159
+ throw new ContactError("invalid_field", { field: "preferredContact" });
160
+ }
142
161
  function contactIdentityKey(kind, name, companyOrRole = "") {
143
162
  const n = collapse(name).toLocaleLowerCase("de-DE");
144
163
  if (kind === "organization") return n;
@@ -151,6 +170,108 @@ function formatCustomerNumber(n, width = 4) {
151
170
  return String(n).padStart(width, "0");
152
171
  }
153
172
 
173
+ // src/fields.ts
174
+ var FIELD_KEY = /^[a-z][a-z0-9_]{1,39}$/;
175
+ var FIELD_LABEL_MAX = 80;
176
+ var FIELD_OPTION_MAX = 80;
177
+ var FIELD_OPTIONS_MAX = 100;
178
+ var FIELD_TEXT_MAX = 2e3;
179
+ var FIELD_URL_MAX = 2048;
180
+ var collapse2 = (text) => text.replace(/\s+/g, " ").trim();
181
+ function normalizeFieldDefinition(input) {
182
+ const key = String(input.key ?? "");
183
+ if (!FIELD_KEY.test(key)) throw new ContactError("invalid_field", { field: "key" });
184
+ const label = collapse2(String(input.label ?? ""));
185
+ if (!label) throw new ContactError("invalid_field", { field: "label" });
186
+ if (label.length > FIELD_LABEL_MAX) throw new ContactError("too_long", { field: "label" });
187
+ if (!FIELD_TYPES.includes(input.type)) {
188
+ throw new ContactError("invalid_field", { field: "type" });
189
+ }
190
+ const type = input.type;
191
+ const choosable = type === "select" || type === "multi_select";
192
+ if (!choosable) {
193
+ if (input.options !== void 0 && input.options !== null) {
194
+ throw new ContactError("invalid_field", { field: "options" });
195
+ }
196
+ return { key, label, type, options: null };
197
+ }
198
+ if (!Array.isArray(input.options) || input.options.length === 0 || input.options.length > FIELD_OPTIONS_MAX) {
199
+ throw new ContactError("invalid_field", { field: "options" });
200
+ }
201
+ const options = input.options.map((o) => collapse2(String(o)));
202
+ for (const option of options) {
203
+ if (!option) throw new ContactError("invalid_field", { field: "options" });
204
+ if (option.length > FIELD_OPTION_MAX) throw new ContactError("too_long", { field: "options" });
205
+ }
206
+ if (new Set(options).size !== options.length) throw new ContactError("invalid_field", { field: "options" });
207
+ return { key, label, type, options };
208
+ }
209
+ var ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
210
+ function checkFieldValue(def, value) {
211
+ const refuse = () => new ContactError("invalid_value", { field: def.key });
212
+ switch (def.type) {
213
+ case "text": {
214
+ if (typeof value !== "string" || value.length === 0 || value.length > FIELD_TEXT_MAX) throw refuse();
215
+ if (value !== value.trim()) throw refuse();
216
+ return value;
217
+ }
218
+ case "number": {
219
+ if (typeof value !== "number" || !Number.isFinite(value)) throw refuse();
220
+ return value;
221
+ }
222
+ case "date": {
223
+ if (typeof value !== "string") throw refuse();
224
+ const match = ISO_DAY.exec(value);
225
+ if (!match) throw refuse();
226
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
227
+ const date = new Date(Date.UTC(year, month - 1, day));
228
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) throw refuse();
229
+ return value;
230
+ }
231
+ case "boolean": {
232
+ if (typeof value !== "boolean") throw refuse();
233
+ return value;
234
+ }
235
+ case "select": {
236
+ if (typeof value !== "string" || !def.options?.includes(value)) throw refuse();
237
+ return value;
238
+ }
239
+ case "multi_select": {
240
+ if (!Array.isArray(value) || value.length === 0) throw refuse();
241
+ if (!value.every((v) => typeof v === "string" && def.options?.includes(v))) throw refuse();
242
+ if (new Set(value).size !== value.length) throw refuse();
243
+ return [...value];
244
+ }
245
+ case "url": {
246
+ if (typeof value !== "string" || value.length > FIELD_URL_MAX) throw refuse();
247
+ let parsed;
248
+ try {
249
+ parsed = new URL(value);
250
+ } catch {
251
+ throw refuse();
252
+ }
253
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw refuse();
254
+ return value;
255
+ }
256
+ }
257
+ }
258
+ function applyCustomFieldPatch(current, patch, definitions) {
259
+ const byKey = new Map(definitions.map((d) => [d.key, d]));
260
+ const next = { ...current };
261
+ for (const [key, value] of Object.entries(patch)) {
262
+ if (value === void 0) continue;
263
+ const def = byKey.get(key);
264
+ if (!def) throw new ContactError("unknown_field", { field: key });
265
+ if (value === null) {
266
+ delete next[key];
267
+ continue;
268
+ }
269
+ if (def.archived) throw new ContactError("field_archived", { field: key });
270
+ next[key] = checkFieldValue(def, value);
271
+ }
272
+ return next;
273
+ }
274
+
154
275
  // src/db.ts
155
276
  var import_kysely = require("kysely");
156
277
  var import_kysely_postgres_js = require("kysely-postgres-js");
@@ -212,6 +333,37 @@ CREATE TABLE tenant_counters (
212
333
  tenant_id text PRIMARY KEY CHECK (char_length(tenant_id) BETWEEN 1 AND 64),
213
334
  next_customer_number integer NOT NULL DEFAULT 1 CHECK (next_customer_number > 0)
214
335
  );
336
+ `
337
+ },
338
+ {
339
+ name: "0002_contact_fields",
340
+ sql: `
341
+ -- A preferred way to be contacted. Null means none was recorded.
342
+ ALTER TABLE contacts
343
+ ADD COLUMN preferred_contact text CHECK (preferred_contact IN ('email', 'phone', 'post', 'none'));
344
+
345
+ -- Values of the company's custom fields, keyed by field key. Checked against the
346
+ -- definitions by the package on every write, so the column itself only insists on an object.
347
+ ALTER TABLE contacts
348
+ ADD COLUMN custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(custom_fields) = 'object');
349
+
350
+ -- One definition per field and company. Archived definitions stay, so the values
351
+ -- stored under them keep their meaning; the package refuses new values for them.
352
+ CREATE TABLE contact_field_definitions (
353
+ tenant_id text NOT NULL CHECK (char_length(tenant_id) BETWEEN 1 AND 64),
354
+ id uuid NOT NULL,
355
+ key text NOT NULL CHECK (key ~ '^[a-z][a-z0-9_]{1,39}$'),
356
+ label text NOT NULL CHECK (char_length(label) BETWEEN 1 AND 80),
357
+ type text NOT NULL CHECK (type IN ('text', 'number', 'date', 'boolean', 'select', 'multi_select', 'url')),
358
+ -- Choices for select and multi_select; absent for every other type.
359
+ options jsonb CHECK (options IS NULL OR jsonb_typeof(options) = 'array'),
360
+ archived_at timestamptz,
361
+ created_at timestamptz NOT NULL DEFAULT now(),
362
+ PRIMARY KEY (tenant_id, id),
363
+ CONSTRAINT contact_field_definitions_key UNIQUE (tenant_id, key),
364
+ CONSTRAINT contact_field_definitions_options
365
+ CHECK ((type IN ('select', 'multi_select')) = (options IS NOT NULL))
366
+ );
215
367
  `
216
368
  }
217
369
  ];
@@ -377,13 +529,25 @@ function toContact(row) {
377
529
  country: row.country,
378
530
  vatId: row.vat_id,
379
531
  customerNumber: row.customer_number,
532
+ preferredContact: row.preferred_contact,
533
+ customFields: row.custom_fields ?? {},
380
534
  version: row.version,
381
535
  archived: row.archived_at !== null,
382
536
  createdAt: row.created_at,
383
537
  updatedAt: row.updated_at
384
538
  };
385
539
  }
386
- function columns(data) {
540
+ function toDefinition(row) {
541
+ return {
542
+ key: row.key,
543
+ label: row.label,
544
+ type: row.type,
545
+ options: row.options,
546
+ archived: row.archived_at !== null,
547
+ createdAt: row.created_at
548
+ };
549
+ }
550
+ function columns(data, customFields) {
387
551
  return {
388
552
  name: data.name,
389
553
  company_or_role: data.companyOrRole,
@@ -398,6 +562,8 @@ function columns(data) {
398
562
  city: data.city,
399
563
  country: data.country,
400
564
  vat_id: data.vatId,
565
+ preferred_contact: data.preferredContact,
566
+ custom_fields: import_kysely2.sql`${JSON.stringify(customFields)}::text::jsonb`,
401
567
  identity_key: contactIdentityKey(data.kind, data.name, data.companyOrRole)
402
568
  };
403
569
  }
@@ -418,6 +584,10 @@ function contactsFor(handle, tenantId) {
418
584
  }
419
585
  const { db } = handle;
420
586
  const own = (trx = db) => trx.selectFrom("contacts").selectAll().where("tenant_id", "=", tenantId);
587
+ async function loadDefinitions(trx = db) {
588
+ const rows = await trx.selectFrom("contact_field_definitions").selectAll().where("tenant_id", "=", tenantId).orderBy("created_at").orderBy("key").execute();
589
+ return rows.map(toDefinition);
590
+ }
421
591
  async function findByIdentity(kind, name, companyOrRole, exceptId) {
422
592
  let q = own().where("kind", "=", kind).where("identity_key", "=", contactIdentityKey(kind, name, companyOrRole));
423
593
  if (exceptId) q = q.where("id", "!=", exceptId);
@@ -489,7 +659,8 @@ function contactsFor(handle, tenantId) {
489
659
  if (!isContactId(id)) throw new ContactError("invalid_field", { field: "id" });
490
660
  await handle.ready();
491
661
  await assertOrganization(data.organizationId);
492
- const inserted = await db.insertInto("contacts").values({ id, tenant_id: tenantId, kind: data.kind, ...columns(data) }).onConflict((oc) => oc.columns(["tenant_id", "kind", "identity_key"]).doNothing()).returningAll().executeTakeFirst();
662
+ const customFields = input.customFields ? applyCustomFieldPatch({}, input.customFields, await loadDefinitions()) : {};
663
+ 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();
493
664
  if (!inserted) {
494
665
  const existing = await findByIdentity(data.kind, data.name, data.companyOrRole);
495
666
  throw new ContactError("duplicate", { existing: existing ?? void 0 });
@@ -513,7 +684,9 @@ function contactsFor(handle, tenantId) {
513
684
  const org = await own(trx).where("id", "=", data.organizationId).executeTakeFirst();
514
685
  if (!org || org.kind !== "organization") throw new ContactError("invalid_organization");
515
686
  }
516
- const updated = await trx.updateTable("contacts").set({ ...columns(data), version: import_kysely2.sql`version + 1`, updated_at: /* @__PURE__ */ new Date() }).where("tenant_id", "=", tenantId).where("id", "=", id).returningAll().executeTakeFirstOrThrow();
687
+ const currentFields = row.custom_fields ?? {};
688
+ const customFields = input.customFields ? applyCustomFieldPatch(currentFields, input.customFields, await loadDefinitions(trx)) : currentFields;
689
+ const updated = await trx.updateTable("contacts").set({ ...columns(data, customFields), version: import_kysely2.sql`version + 1`, updated_at: /* @__PURE__ */ new Date() }).where("tenant_id", "=", tenantId).where("id", "=", id).returningAll().executeTakeFirstOrThrow();
517
690
  return toContact(updated);
518
691
  });
519
692
  } catch (e) {
@@ -524,6 +697,40 @@ function contactsFor(handle, tenantId) {
524
697
  throw e;
525
698
  }
526
699
  },
700
+ async createField(input) {
701
+ await handle.ready();
702
+ const data = normalizeFieldDefinition(input);
703
+ try {
704
+ const row = await db.insertInto("contact_field_definitions").values({
705
+ tenant_id: tenantId,
706
+ id: uuidv7(),
707
+ key: data.key,
708
+ label: data.label,
709
+ type: data.type,
710
+ options: data.options === null ? null : import_kysely2.sql`${JSON.stringify(data.options)}::text::jsonb`
711
+ }).returningAll().executeTakeFirstOrThrow();
712
+ return toDefinition(row);
713
+ } catch (e) {
714
+ if (e.constraint_name === "contact_field_definitions_key") {
715
+ throw new ContactError("duplicate_field", { field: data.key });
716
+ }
717
+ throw e;
718
+ }
719
+ },
720
+ async listFields(options = {}) {
721
+ await handle.ready();
722
+ const definitions = await loadDefinitions();
723
+ return options.includeArchived ? definitions : definitions.filter((d) => !d.archived);
724
+ },
725
+ /** Archives a field: its stored values stay, and new values under it are refused. Repeating it changes nothing. */
726
+ async archiveField(key) {
727
+ await handle.ready();
728
+ const row = await db.selectFrom("contact_field_definitions").selectAll().where("tenant_id", "=", tenantId).where("key", "=", key).executeTakeFirst();
729
+ if (!row) throw new ContactError("field_not_found", { field: key });
730
+ if (row.archived_at !== null) return toDefinition(row);
731
+ const updated = await db.updateTable("contact_field_definitions").set({ archived_at: /* @__PURE__ */ new Date() }).where("tenant_id", "=", tenantId).where("key", "=", key).returningAll().executeTakeFirstOrThrow();
732
+ return toDefinition(updated);
733
+ },
527
734
  archive: (id) => setArchived(id, true),
528
735
  restore: (id) => setArchived(id, false),
529
736
  async merge(loserId, winnerId) {
@@ -801,10 +1008,20 @@ async function importContacts(contacts, rows, options = {}) {
801
1008
  ContactError,
802
1009
  ContactsLayoutError,
803
1010
  CsvError,
1011
+ FIELD_KEY,
1012
+ FIELD_LABEL_MAX,
1013
+ FIELD_OPTIONS_MAX,
1014
+ FIELD_OPTION_MAX,
1015
+ FIELD_TEXT_MAX,
1016
+ FIELD_TYPES,
1017
+ FIELD_URL_MAX,
804
1018
  IMPORT_COLUMNS,
805
1019
  MIGRATIONS,
806
1020
  MigrationError,
807
1021
  ORGANIZATION_NAME_MAX,
1022
+ PREFERRED_CONTACT,
1023
+ applyCustomFieldPatch,
1024
+ checkFieldValue,
808
1025
  contactIdentityKey,
809
1026
  contactsFor,
810
1027
  createContactsDb,
@@ -816,6 +1033,7 @@ async function importContacts(contacts, rows, options = {}) {
816
1033
  migrate,
817
1034
  missingMigrations,
818
1035
  normalizeContactInput,
1036
+ normalizeFieldDefinition,
819
1037
  parseCsv,
820
1038
  uuidv7
821
1039
  });