@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marlinjai/contacts-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The shared contact model of the Lumitra suite: persons and organizations per company in one contacts database, with duplicate detection, customer numbers, merge, erasure and the migration runner.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -8,28 +8,50 @@
|
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
|
-
"import": {
|
|
12
|
-
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
13
19
|
},
|
|
14
20
|
"./model": {
|
|
15
|
-
"import": {
|
|
16
|
-
|
|
21
|
+
"import": {
|
|
22
|
+
"types": "./dist/model.d.ts",
|
|
23
|
+
"default": "./dist/model.js"
|
|
24
|
+
},
|
|
25
|
+
"require": {
|
|
26
|
+
"types": "./dist/model.d.cts",
|
|
27
|
+
"default": "./dist/model.cjs"
|
|
28
|
+
}
|
|
17
29
|
}
|
|
18
30
|
},
|
|
19
31
|
"bin": {
|
|
20
32
|
"contacts-migrate": "./dist/migrate-cli.js",
|
|
21
33
|
"contacts-import": "./dist/import-cli.js"
|
|
22
34
|
},
|
|
23
|
-
"files": [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"CHANGELOG.md"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/marlinjai/contacts.git",
|
|
45
|
+
"directory": "packages/core"
|
|
31
46
|
},
|
|
32
|
-
"keywords": [
|
|
47
|
+
"keywords": [
|
|
48
|
+
"contacts",
|
|
49
|
+
"crm",
|
|
50
|
+
"multi-tenancy",
|
|
51
|
+
"kysely",
|
|
52
|
+
"postgres",
|
|
53
|
+
"lumitra"
|
|
54
|
+
],
|
|
33
55
|
"license": "MIT",
|
|
34
56
|
"dependencies": {
|
|
35
57
|
"kysely": "^0.27.4",
|
|
@@ -39,6 +61,7 @@
|
|
|
39
61
|
"postgres": ">=3.4.5"
|
|
40
62
|
},
|
|
41
63
|
"devDependencies": {
|
|
64
|
+
"@marlinjai/data-table-core": "^0.3.0",
|
|
42
65
|
"@testcontainers/postgresql": "^10.13.0",
|
|
43
66
|
"@types/node": "^22.10.0",
|
|
44
67
|
"postgres": "^3.4.5",
|
|
@@ -46,5 +69,11 @@
|
|
|
46
69
|
"tsup": "^8.0.1",
|
|
47
70
|
"typescript": "^5.6.3",
|
|
48
71
|
"vitest": "^2.1.8"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"build": "rm -rf dist && tsup",
|
|
75
|
+
"typecheck": "tsc --noEmit",
|
|
76
|
+
"test": "vitest run",
|
|
77
|
+
"clean": "rm -rf dist"
|
|
49
78
|
}
|
|
50
|
-
}
|
|
79
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
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\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 /** 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}\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\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}\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 };\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":[]}
|