@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.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-
|
|
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
|
|
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
|
|
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
|
|
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
|
};
|