@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.d.ts CHANGED
@@ -1,8 +1,59 @@
1
- import { Contact, ContactKind, ContactInput } from './model.js';
2
- export { CONTACT_COMPANY_MAX, CONTACT_EMAIL_MAX, CONTACT_NAME_MAX, CONTACT_NOTE_MAX, ContactError, ContactErrorCode, NormalizedContact, ORGANIZATION_NAME_MAX, contactIdentityKey, formatCustomerNumber, formatGuest, isContactId, normalizeContactInput } from './model.js';
3
- import { Kysely, Generated, ColumnType } from 'kysely';
1
+ import { FieldType, FieldValues, FieldValue, Contact, ContactKind, ContactInput } from './model.js';
2
+ export { CONTACT_COMPANY_MAX, CONTACT_EMAIL_MAX, CONTACT_NAME_MAX, CONTACT_NOTE_MAX, ContactError, ContactErrorCode, FIELD_TYPES, NormalizedContact, ORGANIZATION_NAME_MAX, PREFERRED_CONTACT, PreferredContact, contactIdentityKey, formatCustomerNumber, formatGuest, isContactId, normalizeContactInput } from './model.js';
3
+ import { Kysely, Generated, ColumnType, Expression } from 'kysely';
4
4
  import { Sql } from 'postgres';
5
5
 
6
+ /**
7
+ * Custom field definitions and the checks that every stored value must pass.
8
+ *
9
+ * The type names are the data table's column type names (`ColumnType` in
10
+ * @marlinjai/data-table-core), which export no value validation, so the rules
11
+ * live here. A value is either accepted exactly as given, or refused with a
12
+ * typed error that names the field. Nothing is coerced: a number is never read
13
+ * from a string, and a date must be a real calendar day.
14
+ */
15
+ declare const FIELD_KEY: RegExp;
16
+ declare const FIELD_LABEL_MAX = 80;
17
+ declare const FIELD_OPTION_MAX = 80;
18
+ declare const FIELD_OPTIONS_MAX = 100;
19
+ declare const FIELD_TEXT_MAX = 2000;
20
+ declare const FIELD_URL_MAX = 2048;
21
+ interface FieldDefinition {
22
+ key: string;
23
+ label: string;
24
+ type: FieldType;
25
+ /** Choices for select and multi_select; null for every other type. */
26
+ options: string[] | null;
27
+ archived: boolean;
28
+ createdAt: Date;
29
+ }
30
+ interface FieldDefinitionInput {
31
+ key: string;
32
+ label: string;
33
+ type: string;
34
+ options?: readonly string[] | null;
35
+ }
36
+ interface NormalizedFieldDefinition {
37
+ key: string;
38
+ label: string;
39
+ type: FieldType;
40
+ options: string[] | null;
41
+ }
42
+ /** Validates a definition before it is stored. Throws ContactError('invalid_field' | 'too_long'). */
43
+ declare function normalizeFieldDefinition(input: FieldDefinitionInput): NormalizedFieldDefinition;
44
+ /** Checks one value against its definition. Returns the value as stored. Throws ContactError. */
45
+ declare function checkFieldValue(def: FieldDefinition, value: unknown): FieldValue;
46
+ /**
47
+ * Applies a patch of custom field values to a contact's current values.
48
+ *
49
+ * - A key without a definition is refused (`unknown_field`), never dropped.
50
+ * - A key whose definition is archived is refused (`field_archived`) when a value
51
+ * is set. Clearing it (null) is allowed, so an archived field can be emptied.
52
+ * - A null value removes the key. Keys not in the patch are left as they are,
53
+ * including values under archived definitions.
54
+ */
55
+ declare function applyCustomFieldPatch(current: FieldValues, patch: Record<string, unknown>, definitions: readonly FieldDefinition[]): FieldValues;
56
+
6
57
  /**
7
58
  * The table changes of the contacts database, in order.
8
59
  *
@@ -44,18 +95,33 @@ interface ContactsTable {
44
95
  country: string | null;
45
96
  vat_id: string | null;
46
97
  customer_number: number | null;
98
+ preferred_contact: string | null;
99
+ /** Written as a cast JSON expression (see columns in contacts.ts); read back as the parsed object. */
100
+ custom_fields: ColumnType<Record<string, unknown>, Expression<unknown> | undefined, Expression<unknown>>;
47
101
  identity_key: string;
48
102
  version: Generated<number>;
49
103
  archived_at: ColumnType<Date | null, Date | string | null | undefined, Date | string | null>;
50
104
  created_at: Timestamp;
51
105
  updated_at: Timestamp;
52
106
  }
107
+ interface ContactFieldDefinitionsTable {
108
+ tenant_id: string;
109
+ id: string;
110
+ key: string;
111
+ label: string;
112
+ type: string;
113
+ /** Written as a cast JSON expression; read back as the parsed array (null for non-choice types). */
114
+ options: ColumnType<string[] | null, Expression<unknown> | null | undefined, Expression<unknown> | null>;
115
+ archived_at: ColumnType<Date | null, Date | string | null | undefined, Date | string | null>;
116
+ created_at: Timestamp;
117
+ }
53
118
  interface TenantCountersTable {
54
119
  tenant_id: string;
55
120
  next_customer_number: Generated<number>;
56
121
  }
57
122
  interface Database {
58
123
  contacts: ContactsTable;
124
+ contact_field_definitions: ContactFieldDefinitionsTable;
59
125
  tenant_counters: TenantCountersTable;
60
126
  }
61
127
  /**
@@ -153,6 +219,14 @@ interface Contacts {
153
219
  setCustomerNumber(id: string, customerNumber: number): Promise<Contact>;
154
220
  /** The number the next `assignCustomerNumber` would hand out. */
155
221
  peekNextCustomerNumber(): Promise<number>;
222
+ /** Defines a custom field for this company. Its key is unique per company; a key of an archived field stays taken. */
223
+ createField(input: FieldDefinitionInput): Promise<FieldDefinition>;
224
+ /** The company's custom fields. Archived ones are left out unless asked for. */
225
+ listFields(options?: {
226
+ includeArchived?: boolean;
227
+ }): Promise<FieldDefinition[]>;
228
+ /** Archives a custom field by key. Stored values stay; new values are refused. */
229
+ archiveField(key: string): Promise<FieldDefinition>;
156
230
  /** Deletes one contact for good. Persons linked to an erased organization keep their record, unlinked. */
157
231
  erase(id: string): Promise<boolean>;
158
232
  /** Deletes every contact and the counter of this company. Safe to repeat. Returns the number of contacts removed. */
@@ -234,4 +308,4 @@ declare function importContacts(contacts: Contacts, rows: readonly ImportRow[],
234
308
  */
235
309
  declare function uuidv7(now?: number): string;
236
310
 
237
- export { Contact, type ContactExport, ContactInput, ContactKind, type Contacts, type ContactsDb, type ContactsDbOptions, ContactsLayoutError, CsvError, IMPORT_COLUMNS, type ImportReport, type ImportRow, type ListOptions, MIGRATIONS, type MigrateResult, type Migration, MigrationError, type MigrationSource, contactsFor, createContactsDb, importContacts, loadMigrations, migrate, missingMigrations, parseCsv, uuidv7 };
311
+ export { Contact, type ContactExport, ContactInput, ContactKind, type Contacts, type ContactsDb, type ContactsDbOptions, ContactsLayoutError, CsvError, FIELD_KEY, FIELD_LABEL_MAX, FIELD_OPTIONS_MAX, FIELD_OPTION_MAX, FIELD_TEXT_MAX, FIELD_URL_MAX, type FieldDefinition, type FieldDefinitionInput, FieldType, FieldValue, FieldValues, IMPORT_COLUMNS, type ImportReport, type ImportRow, type ListOptions, MIGRATIONS, type MigrateResult, type Migration, MigrationError, type MigrationSource, type NormalizedFieldDefinition, applyCustomFieldPatch, checkFieldValue, contactsFor, createContactsDb, importContacts, loadMigrations, migrate, missingMigrations, normalizeFieldDefinition, parseCsv, uuidv7 };
package/dist/index.js CHANGED
@@ -4,13 +4,117 @@ import {
4
4
  CONTACT_NAME_MAX,
5
5
  CONTACT_NOTE_MAX,
6
6
  ContactError,
7
+ FIELD_TYPES,
7
8
  ORGANIZATION_NAME_MAX,
9
+ PREFERRED_CONTACT,
8
10
  contactIdentityKey,
9
11
  formatCustomerNumber,
10
12
  formatGuest,
11
13
  isContactId,
12
14
  normalizeContactInput
13
- } from "./chunk-FPZSYMBL.js";
15
+ } from "./chunk-5LG5GEWU.js";
16
+
17
+ // src/fields.ts
18
+ var FIELD_KEY = /^[a-z][a-z0-9_]{1,39}$/;
19
+ var FIELD_LABEL_MAX = 80;
20
+ var FIELD_OPTION_MAX = 80;
21
+ var FIELD_OPTIONS_MAX = 100;
22
+ var FIELD_TEXT_MAX = 2e3;
23
+ var FIELD_URL_MAX = 2048;
24
+ var collapse = (text) => text.replace(/\s+/g, " ").trim();
25
+ function normalizeFieldDefinition(input) {
26
+ const key = String(input.key ?? "");
27
+ if (!FIELD_KEY.test(key)) throw new ContactError("invalid_field", { field: "key" });
28
+ const label = collapse(String(input.label ?? ""));
29
+ if (!label) throw new ContactError("invalid_field", { field: "label" });
30
+ if (label.length > FIELD_LABEL_MAX) throw new ContactError("too_long", { field: "label" });
31
+ if (!FIELD_TYPES.includes(input.type)) {
32
+ throw new ContactError("invalid_field", { field: "type" });
33
+ }
34
+ const type = input.type;
35
+ const choosable = type === "select" || type === "multi_select";
36
+ if (!choosable) {
37
+ if (input.options !== void 0 && input.options !== null) {
38
+ throw new ContactError("invalid_field", { field: "options" });
39
+ }
40
+ return { key, label, type, options: null };
41
+ }
42
+ if (!Array.isArray(input.options) || input.options.length === 0 || input.options.length > FIELD_OPTIONS_MAX) {
43
+ throw new ContactError("invalid_field", { field: "options" });
44
+ }
45
+ const options = input.options.map((o) => collapse(String(o)));
46
+ for (const option of options) {
47
+ if (!option) throw new ContactError("invalid_field", { field: "options" });
48
+ if (option.length > FIELD_OPTION_MAX) throw new ContactError("too_long", { field: "options" });
49
+ }
50
+ if (new Set(options).size !== options.length) throw new ContactError("invalid_field", { field: "options" });
51
+ return { key, label, type, options };
52
+ }
53
+ var ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
54
+ function checkFieldValue(def, value) {
55
+ const refuse = () => new ContactError("invalid_value", { field: def.key });
56
+ switch (def.type) {
57
+ case "text": {
58
+ if (typeof value !== "string" || value.length === 0 || value.length > FIELD_TEXT_MAX) throw refuse();
59
+ if (value !== value.trim()) throw refuse();
60
+ return value;
61
+ }
62
+ case "number": {
63
+ if (typeof value !== "number" || !Number.isFinite(value)) throw refuse();
64
+ return value;
65
+ }
66
+ case "date": {
67
+ if (typeof value !== "string") throw refuse();
68
+ const match = ISO_DAY.exec(value);
69
+ if (!match) throw refuse();
70
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
71
+ const date = new Date(Date.UTC(year, month - 1, day));
72
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) throw refuse();
73
+ return value;
74
+ }
75
+ case "boolean": {
76
+ if (typeof value !== "boolean") throw refuse();
77
+ return value;
78
+ }
79
+ case "select": {
80
+ if (typeof value !== "string" || !def.options?.includes(value)) throw refuse();
81
+ return value;
82
+ }
83
+ case "multi_select": {
84
+ if (!Array.isArray(value) || value.length === 0) throw refuse();
85
+ if (!value.every((v) => typeof v === "string" && def.options?.includes(v))) throw refuse();
86
+ if (new Set(value).size !== value.length) throw refuse();
87
+ return [...value];
88
+ }
89
+ case "url": {
90
+ if (typeof value !== "string" || value.length > FIELD_URL_MAX) throw refuse();
91
+ let parsed;
92
+ try {
93
+ parsed = new URL(value);
94
+ } catch {
95
+ throw refuse();
96
+ }
97
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw refuse();
98
+ return value;
99
+ }
100
+ }
101
+ }
102
+ function applyCustomFieldPatch(current, patch, definitions) {
103
+ const byKey = new Map(definitions.map((d) => [d.key, d]));
104
+ const next = { ...current };
105
+ for (const [key, value] of Object.entries(patch)) {
106
+ if (value === void 0) continue;
107
+ const def = byKey.get(key);
108
+ if (!def) throw new ContactError("unknown_field", { field: key });
109
+ if (value === null) {
110
+ delete next[key];
111
+ continue;
112
+ }
113
+ if (def.archived) throw new ContactError("field_archived", { field: key });
114
+ next[key] = checkFieldValue(def, value);
115
+ }
116
+ return next;
117
+ }
14
118
 
15
119
  // src/db.ts
16
120
  import { Kysely } from "kysely";
@@ -73,6 +177,37 @@ CREATE TABLE tenant_counters (
73
177
  tenant_id text PRIMARY KEY CHECK (char_length(tenant_id) BETWEEN 1 AND 64),
74
178
  next_customer_number integer NOT NULL DEFAULT 1 CHECK (next_customer_number > 0)
75
179
  );
180
+ `
181
+ },
182
+ {
183
+ name: "0002_contact_fields",
184
+ sql: `
185
+ -- A preferred way to be contacted. Null means none was recorded.
186
+ ALTER TABLE contacts
187
+ ADD COLUMN preferred_contact text CHECK (preferred_contact IN ('email', 'phone', 'post', 'none'));
188
+
189
+ -- Values of the company's custom fields, keyed by field key. Checked against the
190
+ -- definitions by the package on every write, so the column itself only insists on an object.
191
+ ALTER TABLE contacts
192
+ ADD COLUMN custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(custom_fields) = 'object');
193
+
194
+ -- One definition per field and company. Archived definitions stay, so the values
195
+ -- stored under them keep their meaning; the package refuses new values for them.
196
+ CREATE TABLE contact_field_definitions (
197
+ tenant_id text NOT NULL CHECK (char_length(tenant_id) BETWEEN 1 AND 64),
198
+ id uuid NOT NULL,
199
+ key text NOT NULL CHECK (key ~ '^[a-z][a-z0-9_]{1,39}$'),
200
+ label text NOT NULL CHECK (char_length(label) BETWEEN 1 AND 80),
201
+ type text NOT NULL CHECK (type IN ('text', 'number', 'date', 'boolean', 'select', 'multi_select', 'url')),
202
+ -- Choices for select and multi_select; absent for every other type.
203
+ options jsonb CHECK (options IS NULL OR jsonb_typeof(options) = 'array'),
204
+ archived_at timestamptz,
205
+ created_at timestamptz NOT NULL DEFAULT now(),
206
+ PRIMARY KEY (tenant_id, id),
207
+ CONSTRAINT contact_field_definitions_key UNIQUE (tenant_id, key),
208
+ CONSTRAINT contact_field_definitions_options
209
+ CHECK ((type IN ('select', 'multi_select')) = (options IS NOT NULL))
210
+ );
76
211
  `
77
212
  }
78
213
  ];
@@ -238,13 +373,25 @@ function toContact(row) {
238
373
  country: row.country,
239
374
  vatId: row.vat_id,
240
375
  customerNumber: row.customer_number,
376
+ preferredContact: row.preferred_contact,
377
+ customFields: row.custom_fields ?? {},
241
378
  version: row.version,
242
379
  archived: row.archived_at !== null,
243
380
  createdAt: row.created_at,
244
381
  updatedAt: row.updated_at
245
382
  };
246
383
  }
247
- function columns(data) {
384
+ function toDefinition(row) {
385
+ return {
386
+ key: row.key,
387
+ label: row.label,
388
+ type: row.type,
389
+ options: row.options,
390
+ archived: row.archived_at !== null,
391
+ createdAt: row.created_at
392
+ };
393
+ }
394
+ function columns(data, customFields) {
248
395
  return {
249
396
  name: data.name,
250
397
  company_or_role: data.companyOrRole,
@@ -259,6 +406,8 @@ function columns(data) {
259
406
  city: data.city,
260
407
  country: data.country,
261
408
  vat_id: data.vatId,
409
+ preferred_contact: data.preferredContact,
410
+ custom_fields: sql`${JSON.stringify(customFields)}::text::jsonb`,
262
411
  identity_key: contactIdentityKey(data.kind, data.name, data.companyOrRole)
263
412
  };
264
413
  }
@@ -279,6 +428,10 @@ function contactsFor(handle, tenantId) {
279
428
  }
280
429
  const { db } = handle;
281
430
  const own = (trx = db) => trx.selectFrom("contacts").selectAll().where("tenant_id", "=", tenantId);
431
+ async function loadDefinitions(trx = db) {
432
+ const rows = await trx.selectFrom("contact_field_definitions").selectAll().where("tenant_id", "=", tenantId).orderBy("created_at").orderBy("key").execute();
433
+ return rows.map(toDefinition);
434
+ }
282
435
  async function findByIdentity(kind, name, companyOrRole, exceptId) {
283
436
  let q = own().where("kind", "=", kind).where("identity_key", "=", contactIdentityKey(kind, name, companyOrRole));
284
437
  if (exceptId) q = q.where("id", "!=", exceptId);
@@ -350,7 +503,8 @@ function contactsFor(handle, tenantId) {
350
503
  if (!isContactId(id)) throw new ContactError("invalid_field", { field: "id" });
351
504
  await handle.ready();
352
505
  await assertOrganization(data.organizationId);
353
- 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();
506
+ const customFields = input.customFields ? applyCustomFieldPatch({}, input.customFields, await loadDefinitions()) : {};
507
+ 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();
354
508
  if (!inserted) {
355
509
  const existing = await findByIdentity(data.kind, data.name, data.companyOrRole);
356
510
  throw new ContactError("duplicate", { existing: existing ?? void 0 });
@@ -374,7 +528,9 @@ function contactsFor(handle, tenantId) {
374
528
  const org = await own(trx).where("id", "=", data.organizationId).executeTakeFirst();
375
529
  if (!org || org.kind !== "organization") throw new ContactError("invalid_organization");
376
530
  }
377
- const updated = await trx.updateTable("contacts").set({ ...columns(data), version: sql`version + 1`, updated_at: /* @__PURE__ */ new Date() }).where("tenant_id", "=", tenantId).where("id", "=", id).returningAll().executeTakeFirstOrThrow();
531
+ const currentFields = row.custom_fields ?? {};
532
+ const customFields = input.customFields ? applyCustomFieldPatch(currentFields, input.customFields, await loadDefinitions(trx)) : currentFields;
533
+ 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();
378
534
  return toContact(updated);
379
535
  });
380
536
  } catch (e) {
@@ -385,6 +541,40 @@ function contactsFor(handle, tenantId) {
385
541
  throw e;
386
542
  }
387
543
  },
544
+ async createField(input) {
545
+ await handle.ready();
546
+ const data = normalizeFieldDefinition(input);
547
+ try {
548
+ const row = await db.insertInto("contact_field_definitions").values({
549
+ tenant_id: tenantId,
550
+ id: uuidv7(),
551
+ key: data.key,
552
+ label: data.label,
553
+ type: data.type,
554
+ options: data.options === null ? null : sql`${JSON.stringify(data.options)}::text::jsonb`
555
+ }).returningAll().executeTakeFirstOrThrow();
556
+ return toDefinition(row);
557
+ } catch (e) {
558
+ if (e.constraint_name === "contact_field_definitions_key") {
559
+ throw new ContactError("duplicate_field", { field: data.key });
560
+ }
561
+ throw e;
562
+ }
563
+ },
564
+ async listFields(options = {}) {
565
+ await handle.ready();
566
+ const definitions = await loadDefinitions();
567
+ return options.includeArchived ? definitions : definitions.filter((d) => !d.archived);
568
+ },
569
+ /** Archives a field: its stored values stay, and new values under it are refused. Repeating it changes nothing. */
570
+ async archiveField(key) {
571
+ await handle.ready();
572
+ const row = await db.selectFrom("contact_field_definitions").selectAll().where("tenant_id", "=", tenantId).where("key", "=", key).executeTakeFirst();
573
+ if (!row) throw new ContactError("field_not_found", { field: key });
574
+ if (row.archived_at !== null) return toDefinition(row);
575
+ const updated = await db.updateTable("contact_field_definitions").set({ archived_at: /* @__PURE__ */ new Date() }).where("tenant_id", "=", tenantId).where("key", "=", key).returningAll().executeTakeFirstOrThrow();
576
+ return toDefinition(updated);
577
+ },
388
578
  archive: (id) => setArchived(id, true),
389
579
  restore: (id) => setArchived(id, false),
390
580
  async merge(loserId, winnerId) {
@@ -661,10 +851,20 @@ export {
661
851
  ContactError,
662
852
  ContactsLayoutError,
663
853
  CsvError,
854
+ FIELD_KEY,
855
+ FIELD_LABEL_MAX,
856
+ FIELD_OPTIONS_MAX,
857
+ FIELD_OPTION_MAX,
858
+ FIELD_TEXT_MAX,
859
+ FIELD_TYPES,
860
+ FIELD_URL_MAX,
664
861
  IMPORT_COLUMNS,
665
862
  MIGRATIONS,
666
863
  MigrationError,
667
864
  ORGANIZATION_NAME_MAX,
865
+ PREFERRED_CONTACT,
866
+ applyCustomFieldPatch,
867
+ checkFieldValue,
668
868
  contactIdentityKey,
669
869
  contactsFor,
670
870
  createContactsDb,
@@ -676,6 +876,7 @@ export {
676
876
  migrate,
677
877
  missingMigrations,
678
878
  normalizeContactInput,
879
+ normalizeFieldDefinition,
679
880
  parseCsv,
680
881
  uuidv7
681
882
  };