@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/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
|
|
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
|
|
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
|
|
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
|
});
|