@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.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/db.ts","../src/migrate.ts","../src/migrations/index.ts","../src/contacts.ts","../src/uuid.ts","../src/import.ts"],"sourcesContent":["import { Kysely, type ColumnType, type Generated } from 'kysely';\nimport { PostgresJSDialect } from 'kysely-postgres-js';\nimport postgres, { type Sql } from 'postgres';\nimport { ContactsLayoutError, missingMigrations } from './migrate.js';\nimport type { MigrationSource } from './migrations/index.js';\n\ntype Timestamp = ColumnType<Date, Date | string | undefined, Date | string>;\n\nexport interface ContactsTable {\n id: string;\n tenant_id: string;\n kind: 'person' | 'organization';\n name: string;\n company_or_role: Generated<string>;\n organization_id: string | null;\n email: string | null;\n phone: string | null;\n note: string | null;\n legal_form: string | null;\n address_line1: string | null;\n address_line2: string | null;\n postal_code: string | null;\n city: string | null;\n country: string | null;\n vat_id: string | null;\n customer_number: number | null;\n identity_key: string;\n version: Generated<number>;\n archived_at: ColumnType<Date | null, Date | string | null | undefined, Date | string | null>;\n created_at: Timestamp;\n updated_at: Timestamp;\n}\n\nexport interface TenantCountersTable {\n tenant_id: string;\n next_customer_number: Generated<number>;\n}\n\nexport interface Database {\n contacts: ContactsTable;\n tenant_counters: TenantCountersTable;\n}\n\n/**\n * One app's handle on the contacts database. Create it once per process.\n *\n * Nothing connects until the first query, so an app starts and runs while the\n * contacts database is unreachable; only the calls that need it fail.\n */\nexport interface ContactsDb {\n readonly sql: Sql;\n readonly db: Kysely<Database>;\n /**\n * Resolves once the database has every migration this build needs. Throws\n * ContactsLayoutError when it is behind. A database that is ahead is fine.\n * The positive answer is remembered; a failure is asked again next time.\n */\n ready(): Promise<void>;\n close(): Promise<void>;\n}\n\nexport interface ContactsDbOptions {\n /** Shown in the database's connection list and logs, e.g. \"receipts\". Required: it is how a write is attributed. */\n applicationName: string;\n /** Connections this process may hold. Keep it small: many apps share one small instance. Default 3. */\n max?: number;\n /** Seconds before an idle connection is closed. Default 20. */\n idleTimeoutSeconds?: number;\n /** Seconds to wait for a connection before failing. Default 10. */\n connectTimeoutSeconds?: number;\n /** Tests only: the migrations `ready()` checks for. */\n migrations?: readonly MigrationSource[];\n}\n\nexport function createContactsDb(connectionString: string, options: ContactsDbOptions): ContactsDb {\n if (!connectionString) throw new TypeError('createContactsDb: a connection string is required');\n if (!options?.applicationName) throw new TypeError('createContactsDb: applicationName is required');\n const sql = postgres(connectionString, {\n max: options.max ?? 3,\n idle_timeout: options.idleTimeoutSeconds ?? 20,\n connect_timeout: options.connectTimeoutSeconds ?? 10,\n connection: { application_name: `contacts:${options.applicationName}`.slice(0, 63) },\n onnotice: () => {},\n });\n const db = new Kysely<Database>({ dialect: new PostgresJSDialect({ postgres: sql }) });\n\n let checked: Promise<void> | null = null;\n const ready = (): Promise<void> => {\n if (!checked) {\n const attempt = missingMigrations(sql, options.migrations).then((missing) => {\n if (missing.length > 0) throw new ContactsLayoutError(missing);\n });\n checked = attempt;\n // A failure (database down, layout behind) is not remembered.\n attempt.catch(() => {\n if (checked === attempt) checked = null;\n });\n }\n return checked;\n };\n\n return {\n sql,\n db,\n ready,\n close: async () => {\n await db.destroy();\n await sql.end({ timeout: 5 });\n },\n };\n}\n","import { createHash } from 'node:crypto';\nimport type { Sql } from 'postgres';\nimport { MIGRATIONS, type MigrationSource } from './migrations/index.js';\n\n/**\n * The migrations runner (the mail service's runner, with the migrations in\n * code). Each app that uses the contacts database runs it at start, before it\n * serves.\n *\n * - Each migration applies in its own transaction together with its\n * `_migrations` row, so a failing one leaves nothing half-applied and the\n * next run resumes from it.\n * - Every transaction takes the same advisory lock first and re-checks what is\n * applied, so two apps starting at once apply each migration exactly once.\n * - An applied migration whose text has since changed stops the run: applied\n * migrations are history, and a change belongs in a new one.\n * - A migration the database has but this build does not know is what an app\n * on an older version of the package sees. Migrations are additive, so the\n * older code keeps working: it is reported, not fatal.\n */\n\n/** Arbitrary, fixed: \"lumitra contacts migrations\". */\nconst LOCK_KEY = 7_382_014_552;\nconst NAME_PATTERN = /^(\\d{4})_[a-z0-9_]+$/;\n\nexport type Migration = MigrationSource & { checksum: string };\n\nexport class MigrationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'MigrationError';\n }\n}\n\nexport function loadMigrations(sources: readonly MigrationSource[] = MIGRATIONS): Migration[] {\n const seen = new Set<string>();\n const out: Migration[] = [];\n for (const m of sources) {\n const match = NAME_PATTERN.exec(m.name);\n if (!match) throw new MigrationError(`${m.name}: migrations are named NNNN_lowercase_name`);\n if (seen.has(match[1]!)) throw new MigrationError(`${m.name}: two migrations share the number ${match[1]}`);\n seen.add(match[1]!);\n out.push({ ...m, checksum: createHash('sha256').update(m.sql).digest('hex') });\n }\n return out.sort((a, b) => a.name.localeCompare(b.name));\n}\n\nexport type MigrateResult = { applied: string[]; alreadyApplied: number; unknown: string[] };\n\nexport async function migrate(\n sql: Sql,\n options: { migrations?: readonly MigrationSource[]; log?: (line: string) => void } = {},\n): Promise<MigrateResult> {\n const log = options.log ?? ((line: string) => console.log(`[contacts-migrate] ${line}`));\n const migrations = loadMigrations(options.migrations);\n\n await sql.begin(async (tx) => {\n await tx`SELECT pg_advisory_xact_lock(${LOCK_KEY})`;\n await tx`\n CREATE TABLE IF NOT EXISTS _migrations (\n name text PRIMARY KEY,\n checksum text NOT NULL,\n applied_at timestamptz NOT NULL DEFAULT now()\n )`;\n });\n\n const recorded = await sql<{ name: string; checksum: string }[]>`SELECT name, checksum FROM _migrations`;\n const byName = new Map(recorded.map((r) => [r.name, r.checksum]));\n for (const m of migrations) {\n const checksum = byName.get(m.name);\n if (checksum !== undefined && checksum !== m.checksum) {\n throw new MigrationError(\n `${m.name} was changed after it was applied. Applied migrations are never edited; put the change in a new one.`,\n );\n }\n }\n const known = new Set(migrations.map((m) => m.name));\n const unknown = recorded.map((r) => r.name).filter((n) => !known.has(n));\n for (const name of unknown) {\n log(`note: ${name} is applied in the database but not part of this build (another app runs a newer package)`);\n }\n\n const applied: string[] = [];\n for (const m of migrations) {\n const didApply = await sql.begin(async (tx) => {\n await tx`SELECT pg_advisory_xact_lock(${LOCK_KEY})`;\n const done = await tx`SELECT 1 FROM _migrations WHERE name = ${m.name}`;\n if (done.length > 0) return false;\n await tx.unsafe(m.sql);\n await tx`INSERT INTO _migrations (name, checksum) VALUES (${m.name}, ${m.checksum})`;\n return true;\n });\n if (didApply) {\n applied.push(m.name);\n log(`applied ${m.name}`);\n }\n }\n const alreadyApplied = migrations.length - applied.length;\n log(applied.length === 0 ? `up to date (${alreadyApplied} applied)` : `done: ${applied.length} new, ${alreadyApplied} before`);\n return { applied, alreadyApplied, unknown };\n}\n\nexport class ContactsLayoutError extends Error {\n readonly missing: string[];\n constructor(missing: string[]) {\n super(\n `The contacts database is behind this version of @marlinjai/contacts-core: missing ${missing.join(', ')}. ` +\n 'Run contacts-migrate (or migrate()) before using it.',\n );\n this.name = 'ContactsLayoutError';\n this.missing = missing;\n }\n}\n\n/**\n * The names of the migrations this build needs and the database lacks. Empty\n * means usable. A database that is AHEAD (it has migrations this build does not\n * know) is fine, because migrations are additive.\n */\nexport async function missingMigrations(\n sql: Sql,\n sources: readonly MigrationSource[] = MIGRATIONS,\n): Promise<string[]> {\n const needed = loadMigrations(sources).map((m) => m.name);\n const table = await sql<{ present: boolean }[]>`SELECT to_regclass('_migrations') IS NOT NULL AS present`;\n if (!table[0]?.present) return needed;\n const rows = await sql<{ name: string }[]>`SELECT name FROM _migrations`;\n const have = new Set(rows.map((r) => r.name));\n return needed.filter((n) => !have.has(n));\n}\n","/**\n * The table changes of the contacts database, in order.\n *\n * They live in code, not in .sql files, so they travel inside the bundle of\n * whatever app imports this package (a Next.js standalone build copies no\n * loose files out of node_modules).\n *\n * RULES\n * - Additive only: add a table, a column or an index. Never rename or drop in\n * the same release that stops using something; several apps on different\n * versions of this package share this database. A removal ships only after\n * every app runs a version that no longer reads the thing.\n * - An applied migration is history. Never edit one; add a new one. The\n * runner refuses to continue when the text of an applied migration changed.\n */\n\nexport interface MigrationSource {\n /** `NNNN_lowercase_name`, unique number. */\n name: string;\n sql: string;\n}\n\nexport const MIGRATIONS: readonly MigrationSource[] = [\n {\n name: '0001_contacts',\n sql: `\nCREATE TABLE contacts (\n id uuid PRIMARY KEY,\n -- The auth-brain tenant (the company). Text, because auth-brain ids are\n -- opaque to this package.\n tenant_id text NOT NULL CHECK (char_length(tenant_id) BETWEEN 1 AND 64),\n kind text NOT NULL CHECK (kind IN ('person', 'organization')),\n name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 160),\n -- Persons only. Empty string, not NULL, so it can take part in the identity key.\n company_or_role text NOT NULL DEFAULT '' CHECK (char_length(company_or_role) <= 160),\n organization_id uuid,\n email text,\n phone text,\n note text,\n legal_form text,\n address_line1 text,\n address_line2 text,\n postal_code text,\n city text,\n country text CHECK (country IS NULL OR country ~ '^[A-Z]{2}$'),\n vat_id text,\n customer_number integer CHECK (customer_number IS NULL OR customer_number > 0),\n -- Written by the package (contactIdentityKey); the duplicate check.\n identity_key text NOT NULL,\n version integer NOT NULL DEFAULT 1,\n archived_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT contacts_person_fields CHECK (kind = 'person' OR (company_or_role = '' AND organization_id IS NULL)),\n CONSTRAINT contacts_organization_fields CHECK (kind = 'organization' OR legal_form IS NULL),\n -- Target of the composite foreign key below: a link can only point inside the same company.\n CONSTRAINT contacts_tenant_id_id UNIQUE (tenant_id, id),\n CONSTRAINT contacts_organization_fk FOREIGN KEY (tenant_id, organization_id)\n REFERENCES contacts (tenant_id, id) ON DELETE SET NULL (organization_id)\n);\n\nCREATE UNIQUE INDEX contacts_identity ON contacts (tenant_id, kind, identity_key);\nCREATE UNIQUE INDEX contacts_customer_number ON contacts (tenant_id, customer_number)\n WHERE customer_number IS NOT NULL;\nCREATE INDEX contacts_list ON contacts (tenant_id, name);\nCREATE INDEX contacts_organization ON contacts (tenant_id, organization_id)\n WHERE organization_id IS NOT NULL;\n\n-- One counter per company. A number is taken with a single UPDATE ... RETURNING\n-- in the transaction that sets it on the contact, so it is never issued twice.\nCREATE TABLE tenant_counters (\n tenant_id text PRIMARY KEY CHECK (char_length(tenant_id) BETWEEN 1 AND 64),\n next_customer_number integer NOT NULL DEFAULT 1 CHECK (next_customer_number > 0)\n);\n`,\n },\n];\n","import { sql, type Selectable, type Transaction } from 'kysely';\nimport type { ContactsDb, ContactsTable, Database } from './db.js';\nimport {\n ContactError,\n contactIdentityKey,\n isContactId,\n normalizeContactInput,\n type Contact,\n type ContactInput,\n type ContactKind,\n type NormalizedContact,\n} from './model.js';\nimport { uuidv7 } from './uuid.js';\n\ntype Row = Selectable<ContactsTable>;\n\nfunction toContact(row: Row): Contact {\n return {\n id: row.id,\n kind: row.kind,\n name: row.name,\n companyOrRole: row.company_or_role,\n organizationId: row.organization_id,\n email: row.email,\n phone: row.phone,\n note: row.note,\n legalForm: row.legal_form,\n addressLine1: row.address_line1,\n addressLine2: row.address_line2,\n postalCode: row.postal_code,\n city: row.city,\n country: row.country,\n vatId: row.vat_id,\n customerNumber: row.customer_number,\n version: row.version,\n archived: row.archived_at !== null,\n createdAt: row.created_at,\n updatedAt: row.updated_at,\n };\n}\n\nfunction columns(data: NormalizedContact) {\n return {\n name: data.name,\n company_or_role: data.companyOrRole,\n organization_id: data.organizationId,\n email: data.email,\n phone: data.phone,\n note: data.note,\n legal_form: data.legalForm,\n address_line1: data.addressLine1,\n address_line2: data.addressLine2,\n postal_code: data.postalCode,\n city: data.city,\n country: data.country,\n vat_id: data.vatId,\n identity_key: contactIdentityKey(data.kind, data.name, data.companyOrRole),\n };\n}\n\n/** A unique-index violation on the identity key (Postgres error 23505). */\nfunction isIdentityConflict(e: unknown): boolean {\n const err = e as { code?: string; constraint_name?: string };\n return err?.code === '23505' && err.constraint_name === 'contacts_identity';\n}\n\nfunction isCustomerNumberConflict(e: unknown): boolean {\n const err = e as { code?: string; constraint_name?: string };\n return err?.code === '23505' && err.constraint_name === 'contacts_customer_number';\n}\n\nfunction escapeLike(text: string): string {\n return text.replace(/[\\\\%_]/g, (c) => `\\\\${c}`);\n}\n\nexport interface ListOptions {\n kind?: ContactKind;\n /** Archived contacts are left out unless asked for. */\n includeArchived?: boolean;\n /** Case-insensitive match on name, company or role, and email. */\n search?: string;\n limit?: number;\n}\n\nexport interface ContactExport {\n contact: Contact;\n /** For a person linked to an organization. */\n organization: Contact | null;\n /** For an organization: the persons linked to it. */\n members: Contact[];\n}\n\n/**\n * The contacts of ONE company. Every query this object makes carries that\n * company's id, so an id from another company behaves exactly like an id that\n * does not exist. There is deliberately no way to query without a company.\n */\nexport interface Contacts {\n readonly tenantId: string;\n /** Sorted by name, then company or role. */\n list(options?: ListOptions): Promise<Contact[]>;\n get(id: string): Promise<Contact | null>;\n /** The given ids that exist in THIS company (archived included). Unknown and foreign ids are simply absent. */\n getMany(ids: readonly string[]): Promise<Contact[]>;\n /** The contact that would make `input` a duplicate, if there is one. */\n findSame(input: Pick<ContactInput, 'kind' | 'name' | 'companyOrRole'>, exceptId?: string): Promise<Contact | null>;\n /**\n * Throws `duplicate` (carrying the existing contact) when the identity\n * already exists. `id` is for moving existing data in; leave it out otherwise.\n */\n create(input: ContactInput, options?: { id?: string }): Promise<Contact>;\n /**\n * Corrects a contact; the kind cannot change. With `expectedVersion`, a save\n * based on an older version throws `stale` (carrying the current record)\n * instead of overwriting someone else's change.\n */\n update(id: string, input: ContactInput, options?: { expectedVersion?: number }): Promise<Contact>;\n /** Hides the contact from pickers. Anything that already names it keeps it. */\n archive(id: string): Promise<Contact>;\n restore(id: string): Promise<Contact>;\n /**\n * Folds `loserId` into `winnerId` and deletes the loser: empty fields of the\n * winner are filled from the loser, persons linked to a losing organization\n * move to the winner, a customer number moves when only the loser has one.\n * The CALLER repoints its own rows from the loser's id to the winner's.\n */\n merge(loserId: string, winnerId: string): Promise<Contact>;\n /** Gives the contact the company's next customer number. Returns the existing one when it already has one. */\n assignCustomerNumber(id: string): Promise<Contact>;\n /**\n * Sets a specific, already issued number (importing existing customers) and\n * moves the counter past it. Throws `customer_number_conflict` when the\n * number belongs to someone else or the contact has a different one.\n */\n setCustomerNumber(id: string, customerNumber: number): Promise<Contact>;\n /** The number the next `assignCustomerNumber` would hand out. */\n peekNextCustomerNumber(): Promise<number>;\n /** Deletes one contact for good. Persons linked to an erased organization keep their record, unlinked. */\n erase(id: string): Promise<boolean>;\n /** Deletes every contact and the counter of this company. Safe to repeat. Returns the number of contacts removed. */\n eraseAll(): Promise<number>;\n /** Everything held about one contact. */\n exportContact(id: string): Promise<ContactExport | null>;\n}\n\nexport function contactsFor(handle: ContactsDb, tenantId: string): Contacts {\n if (typeof tenantId !== 'string' || tenantId.length < 1 || tenantId.length > 64) {\n throw new TypeError('contactsFor: tenantId must be a non-empty string of at most 64 characters');\n }\n const { db } = handle;\n\n const own = (trx: Transaction<Database> | typeof db = db) =>\n trx.selectFrom('contacts').selectAll().where('tenant_id', '=', tenantId);\n\n async function findByIdentity(kind: ContactKind, name: string, companyOrRole: string, exceptId?: string) {\n let q = own()\n .where('kind', '=', kind)\n .where('identity_key', '=', contactIdentityKey(kind, name, companyOrRole));\n if (exceptId) q = q.where('id', '!=', exceptId);\n const row = await q.executeTakeFirst();\n return row ? toContact(row) : null;\n }\n\n /** A person's organization link must point at an organization of this company. */\n async function assertOrganization(organizationId: string | null) {\n if (organizationId === null) return;\n const row = await own().where('id', '=', organizationId).executeTakeFirst();\n if (!row || row.kind !== 'organization') throw new ContactError('invalid_organization');\n }\n\n async function lockOwn(trx: Transaction<Database>, id: string): Promise<Row> {\n if (!isContactId(id)) throw new ContactError('not_found');\n const row = await own(trx).where('id', '=', id).forUpdate().executeTakeFirst();\n if (!row) throw new ContactError('not_found');\n return row;\n }\n\n async function setArchived(id: string, archived: boolean): Promise<Contact> {\n await handle.ready();\n return db.transaction().execute(async (trx) => {\n const row = await lockOwn(trx, id);\n if ((row.archived_at !== null) === archived) return toContact(row);\n const updated = await trx\n .updateTable('contacts')\n .set({ archived_at: archived ? new Date() : null, version: sql`version + 1`, updated_at: new Date() })\n .where('tenant_id', '=', tenantId)\n .where('id', '=', id)\n .returningAll()\n .executeTakeFirstOrThrow();\n return toContact(updated);\n });\n }\n\n return {\n tenantId,\n\n async list(options = {}) {\n await handle.ready();\n let q = own();\n if (options.kind) q = q.where('kind', '=', options.kind);\n if (!options.includeArchived) q = q.where('archived_at', 'is', null);\n const term = options.search?.trim();\n if (term) {\n const pattern = `%${escapeLike(term)}%`;\n q = q.where((eb) =>\n eb.or([\n eb('name', 'ilike', pattern),\n eb('company_or_role', 'ilike', pattern),\n eb('email', 'ilike', pattern),\n ]),\n );\n }\n q = q.orderBy('name', 'asc').orderBy('company_or_role', 'asc').orderBy('id', 'asc');\n if (options.limit !== undefined) q = q.limit(Math.max(1, Math.min(options.limit, 5000)));\n return (await q.execute()).map(toContact);\n },\n\n async get(id) {\n if (!isContactId(id)) return null;\n await handle.ready();\n const row = await own().where('id', '=', id).executeTakeFirst();\n return row ? toContact(row) : null;\n },\n\n async getMany(ids) {\n const unique = [...new Set(ids)].filter(isContactId);\n if (unique.length === 0) return [];\n await handle.ready();\n return (await own().where('id', 'in', unique).execute()).map(toContact);\n },\n\n async findSame(input, exceptId) {\n const data = normalizeContactInput({ kind: input.kind, name: input.name, companyOrRole: input.companyOrRole });\n await handle.ready();\n return findByIdentity(data.kind, data.name, data.companyOrRole, exceptId);\n },\n\n async create(input, options = {}) {\n const data = normalizeContactInput(input);\n const id = options.id ?? uuidv7();\n if (!isContactId(id)) throw new ContactError('invalid_field', { field: 'id' });\n await handle.ready();\n await assertOrganization(data.organizationId);\n const inserted = await db\n .insertInto('contacts')\n .values({ id, tenant_id: tenantId, kind: data.kind, ...columns(data) })\n .onConflict((oc) => oc.columns(['tenant_id', 'kind', 'identity_key']).doNothing())\n .returningAll()\n .executeTakeFirst();\n if (!inserted) {\n const existing = await findByIdentity(data.kind, data.name, data.companyOrRole);\n throw new ContactError('duplicate', { existing: existing ?? undefined });\n }\n return toContact(inserted);\n },\n\n async update(id, input, options = {}) {\n await handle.ready();\n let data: NormalizedContact | undefined;\n try {\n return await db.transaction().execute(async (trx) => {\n const row = await lockOwn(trx, id);\n if (input.kind !== undefined && input.kind !== row.kind) {\n throw new ContactError('invalid_field', { field: 'kind' });\n }\n if (options.expectedVersion !== undefined && options.expectedVersion !== row.version) {\n throw new ContactError('stale', { existing: toContact(row) });\n }\n data = normalizeContactInput({ ...input, kind: row.kind });\n if (data.organizationId !== null) {\n const org = await own(trx).where('id', '=', data.organizationId).executeTakeFirst();\n if (!org || org.kind !== 'organization') throw new ContactError('invalid_organization');\n }\n const updated = await trx\n .updateTable('contacts')\n .set({ ...columns(data), version: sql`version + 1`, updated_at: new Date() })\n .where('tenant_id', '=', tenantId)\n .where('id', '=', id)\n .returningAll()\n .executeTakeFirstOrThrow();\n return toContact(updated);\n });\n } catch (e) {\n if (isIdentityConflict(e) && data) {\n const existing = await findByIdentity(data.kind, data.name, data.companyOrRole, id);\n throw new ContactError('duplicate', { existing: existing ?? undefined });\n }\n throw e;\n }\n },\n\n archive: (id) => setArchived(id, true),\n restore: (id) => setArchived(id, false),\n\n async merge(loserId, winnerId) {\n if (loserId === winnerId) throw new ContactError('invalid_field', { field: 'winnerId' });\n await handle.ready();\n return db.transaction().execute(async (trx) => {\n // Lock in id order, so two opposite merges cannot deadlock.\n const [firstId, secondId] = loserId < winnerId ? [loserId, winnerId] : [winnerId, loserId];\n const first = await lockOwn(trx, firstId);\n const second = await lockOwn(trx, secondId);\n const loser = first.id === loserId ? first : second;\n const winner = first.id === winnerId ? first : second;\n if (loser.kind !== winner.kind) throw new ContactError('merge_kind_mismatch');\n if (\n loser.customer_number !== null &&\n winner.customer_number !== null &&\n loser.customer_number !== winner.customer_number\n ) {\n throw new ContactError('customer_number_conflict');\n }\n if (loser.kind === 'organization') {\n await trx\n .updateTable('contacts')\n .set({ organization_id: winner.id, version: sql`version + 1`, updated_at: new Date() })\n .where('tenant_id', '=', tenantId)\n .where('organization_id', '=', loser.id)\n .execute();\n }\n // The loser goes first: its customer number must be free before the winner takes it.\n await trx.deleteFrom('contacts').where('tenant_id', '=', tenantId).where('id', '=', loser.id).execute();\n const updated = await trx\n .updateTable('contacts')\n .set({\n organization_id: winner.organization_id ?? loser.organization_id,\n email: winner.email ?? loser.email,\n phone: winner.phone ?? loser.phone,\n note: winner.note ?? loser.note,\n legal_form: winner.legal_form ?? loser.legal_form,\n address_line1: winner.address_line1 ?? loser.address_line1,\n address_line2: winner.address_line2 ?? loser.address_line2,\n postal_code: winner.postal_code ?? loser.postal_code,\n city: winner.city ?? loser.city,\n country: winner.country ?? loser.country,\n vat_id: winner.vat_id ?? loser.vat_id,\n customer_number: winner.customer_number ?? loser.customer_number,\n // A merge of an archived and an active contact leaves an active one.\n archived_at: winner.archived_at !== null && loser.archived_at !== null ? winner.archived_at : null,\n version: sql`version + 1`,\n updated_at: new Date(),\n })\n .where('tenant_id', '=', tenantId)\n .where('id', '=', winner.id)\n .returningAll()\n .executeTakeFirstOrThrow();\n return toContact(updated);\n });\n },\n\n async assignCustomerNumber(id) {\n await handle.ready();\n return db.transaction().execute(async (trx) => {\n const row = await lockOwn(trx, id);\n if (row.customer_number !== null) return toContact(row);\n const taken = await sql<{ n: number }>`\n INSERT INTO tenant_counters (tenant_id, next_customer_number) VALUES (${tenantId}, 2)\n ON CONFLICT (tenant_id) DO UPDATE SET next_customer_number = tenant_counters.next_customer_number + 1\n RETURNING next_customer_number - 1 AS n`.execute(trx);\n const n = taken.rows[0]!.n;\n const updated = await trx\n .updateTable('contacts')\n .set({ customer_number: n, version: sql`version + 1`, updated_at: new Date() })\n .where('tenant_id', '=', tenantId)\n .where('id', '=', id)\n .returningAll()\n .executeTakeFirstOrThrow();\n return toContact(updated);\n });\n },\n\n async setCustomerNumber(id, customerNumber) {\n if (!Number.isInteger(customerNumber) || customerNumber < 1) {\n throw new ContactError('invalid_field', { field: 'customerNumber' });\n }\n await handle.ready();\n try {\n return await db.transaction().execute(async (trx) => {\n const row = await lockOwn(trx, id);\n if (row.customer_number !== null && row.customer_number !== customerNumber) {\n throw new ContactError('customer_number_conflict');\n }\n await sql`\n INSERT INTO tenant_counters (tenant_id, next_customer_number) VALUES (${tenantId}, ${customerNumber + 1})\n ON CONFLICT (tenant_id) DO UPDATE\n SET next_customer_number = GREATEST(tenant_counters.next_customer_number, ${customerNumber + 1})`.execute(trx);\n if (row.customer_number === customerNumber) return toContact(row);\n const updated = await trx\n .updateTable('contacts')\n .set({ customer_number: customerNumber, version: sql`version + 1`, updated_at: new Date() })\n .where('tenant_id', '=', tenantId)\n .where('id', '=', id)\n .returningAll()\n .executeTakeFirstOrThrow();\n return toContact(updated);\n });\n } catch (e) {\n if (isCustomerNumberConflict(e)) throw new ContactError('customer_number_conflict');\n throw e;\n }\n },\n\n async peekNextCustomerNumber() {\n await handle.ready();\n const row = await db\n .selectFrom('tenant_counters')\n .select('next_customer_number')\n .where('tenant_id', '=', tenantId)\n .executeTakeFirst();\n return row?.next_customer_number ?? 1;\n },\n\n async erase(id) {\n if (!isContactId(id)) return false;\n await handle.ready();\n const result = await db\n .deleteFrom('contacts')\n .where('tenant_id', '=', tenantId)\n .where('id', '=', id)\n .executeTakeFirst();\n return Number(result.numDeletedRows) > 0;\n },\n\n async eraseAll() {\n await handle.ready();\n return db.transaction().execute(async (trx) => {\n const result = await trx.deleteFrom('contacts').where('tenant_id', '=', tenantId).executeTakeFirst();\n await trx.deleteFrom('tenant_counters').where('tenant_id', '=', tenantId).execute();\n return Number(result.numDeletedRows);\n });\n },\n\n async exportContact(id) {\n if (!isContactId(id)) return null;\n await handle.ready();\n const row = await own().where('id', '=', id).executeTakeFirst();\n if (!row) return null;\n const organization = row.organization_id\n ? await own().where('id', '=', row.organization_id).executeTakeFirst()\n : undefined;\n const members =\n row.kind === 'organization'\n ? await own().where('organization_id', '=', row.id).orderBy('name', 'asc').execute()\n : [];\n return {\n contact: toContact(row),\n organization: organization ? toContact(organization) : null,\n members: members.map(toContact),\n };\n },\n };\n}\n","import { randomBytes } from 'node:crypto';\n\n/**\n * A version 7 universally unique identifier (RFC 9562): 48 bits of Unix\n * milliseconds, then random bits. Sorts by creation time and needs no central\n * counter, so any app can create a contact id on its own.\n */\nexport function uuidv7(now: number = Date.now()): string {\n const bytes = randomBytes(16);\n bytes[0] = Math.floor(now / 2 ** 40) & 0xff;\n bytes[1] = Math.floor(now / 2 ** 32) & 0xff;\n bytes[2] = Math.floor(now / 2 ** 24) & 0xff;\n bytes[3] = Math.floor(now / 2 ** 16) & 0xff;\n bytes[4] = Math.floor(now / 2 ** 8) & 0xff;\n bytes[5] = now & 0xff;\n bytes[6] = (bytes[6]! & 0x0f) | 0x70;\n bytes[8] = (bytes[8]! & 0x3f) | 0x80;\n const hex = bytes.toString('hex');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n","import type { Contacts } from './contacts.js';\nimport { ContactError, normalizeContactInput, type ContactInput, type ContactKind } from './model.js';\n\n/**\n * Importing an existing customer list (for example the clients that already\n * carry a hand-assigned customer number).\n *\n * The file is personal data: it lives outside every repository, and nothing\n * here prints a name. Reports speak in row numbers and error codes only.\n */\n\nexport const IMPORT_COLUMNS = [\n 'customer_number',\n 'kind',\n 'name',\n 'company_or_role',\n 'legal_form',\n 'address_line1',\n 'address_line2',\n 'postal_code',\n 'city',\n 'country',\n 'vat_id',\n 'email',\n 'phone',\n 'note',\n] as const;\n\nexport type ImportRow = Partial<Record<(typeof IMPORT_COLUMNS)[number], string>>;\n\nexport class CsvError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'CsvError';\n }\n}\n\n/** A small RFC 4180 reader: comma separated, double quotes, doubled quote inside quotes, first line is the header. */\nexport function parseCsv(text: string): ImportRow[] {\n const records: string[][] = [];\n let field = '';\n let record: string[] = [];\n let quoted = false;\n const src = text.replace(/^/, '');\n for (let i = 0; i < src.length; i++) {\n const c = src[i]!;\n if (quoted) {\n if (c === '\"') {\n if (src[i + 1] === '\"') {\n field += '\"';\n i++;\n } else quoted = false;\n } else field += c;\n } else if (c === '\"') {\n if (field.length > 0) throw new CsvError(`line ${records.length + 1}: a quote inside an unquoted field`);\n quoted = true;\n } else if (c === ',') {\n record.push(field);\n field = '';\n } else if (c === '\\n' || c === '\\r') {\n if (c === '\\r' && src[i + 1] === '\\n') i++;\n record.push(field);\n field = '';\n records.push(record);\n record = [];\n } else field += c;\n }\n if (quoted) throw new CsvError('the file ends inside a quoted field');\n if (field.length > 0 || record.length > 0) {\n record.push(field);\n records.push(record);\n }\n const nonEmpty = records.filter((r) => r.some((f) => f.trim() !== ''));\n const header = nonEmpty.shift();\n if (!header) throw new CsvError('the file is empty');\n const names = header.map((h) => h.trim().toLowerCase());\n const allowed = new Set<string>(IMPORT_COLUMNS);\n for (const n of names) if (!allowed.has(n)) throw new CsvError(`unknown column \"${n}\"`);\n if (!names.includes('name')) throw new CsvError('the column \"name\" is required');\n if (new Set(names).size !== names.length) throw new CsvError('a column appears twice');\n return nonEmpty.map((r, index) => {\n if (r.length > names.length) throw new CsvError(`row ${index + 2}: more fields than columns`);\n const row: ImportRow = {};\n names.forEach((n, i) => {\n row[n as (typeof IMPORT_COLUMNS)[number]] = (r[i] ?? '').trim();\n });\n return row;\n });\n}\n\nexport type ImportOutcome = 'created' | 'numbered' | 'unchanged';\n\nexport interface ImportReport {\n rows: number;\n created: number;\n /** An existing contact that received its customer number. */\n numbered: number;\n unchanged: number;\n /** `row` is the line in the file (the header is line 1). */\n errors: { row: number; code: string; field?: string }[];\n nextCustomerNumber: number;\n}\n\nfunction toInput(row: ImportRow): { input: ContactInput; customerNumber: number | null } {\n const kindRaw = (row.kind ?? '').toLowerCase() || 'organization';\n if (kindRaw !== 'person' && kindRaw !== 'organization') throw new ContactError('invalid_field', { field: 'kind' });\n let customerNumber: number | null = null;\n if (row.customer_number) {\n if (!/^\\d{1,9}$/.test(row.customer_number)) throw new ContactError('invalid_field', { field: 'customer_number' });\n customerNumber = Number.parseInt(row.customer_number, 10);\n if (customerNumber < 1) throw new ContactError('invalid_field', { field: 'customer_number' });\n }\n return {\n customerNumber,\n input: {\n kind: kindRaw as ContactKind,\n name: row.name ?? '',\n companyOrRole: row.company_or_role,\n legalForm: row.legal_form,\n addressLine1: row.address_line1,\n addressLine2: row.address_line2,\n postalCode: row.postal_code,\n city: row.city,\n country: row.country,\n vatId: row.vat_id,\n email: row.email,\n phone: row.phone,\n note: row.note,\n },\n };\n}\n\n/**\n * Creates the contacts of `rows` and sets their customer numbers. Safe to run\n * again: a contact that exists (same kind, name and company) is not created a\n * second time and its fields are left alone; only a missing customer number is\n * set. With `dryRun` nothing is written and the report says what would happen.\n */\nexport async function importContacts(\n contacts: Contacts,\n rows: readonly ImportRow[],\n options: { dryRun?: boolean } = {},\n): Promise<ImportReport> {\n const report: ImportReport = {\n rows: rows.length,\n created: 0,\n numbered: 0,\n unchanged: 0,\n errors: [],\n nextCustomerNumber: 1,\n };\n const numbersInFile = new Map<number, number>();\n for (const [index, row] of rows.entries()) {\n const line = index + 2;\n try {\n const { input, customerNumber } = toInput(row);\n normalizeContactInput(input);\n if (customerNumber !== null) {\n const firstLine = numbersInFile.get(customerNumber);\n if (firstLine !== undefined) throw new ContactError('customer_number_conflict');\n numbersInFile.set(customerNumber, line);\n }\n const existing = await contacts.findSame(input);\n if (existing) {\n if (customerNumber === null || existing.customerNumber === customerNumber) {\n report.unchanged++;\n continue;\n }\n if (existing.customerNumber !== null) throw new ContactError('customer_number_conflict');\n if (!options.dryRun) await contacts.setCustomerNumber(existing.id, customerNumber);\n report.numbered++;\n continue;\n }\n if (!options.dryRun) {\n const created = await contacts.create(input);\n if (customerNumber !== null) {\n try {\n await contacts.setCustomerNumber(created.id, customerNumber);\n } catch (e) {\n // Do not leave a contact behind whose number could not be set.\n await contacts.erase(created.id);\n throw e;\n }\n }\n }\n report.created++;\n } catch (e) {\n if (e instanceof ContactError) report.errors.push({ row: line, code: e.code, field: e.field });\n else throw e;\n }\n }\n const stored = await contacts.peekNextCustomerNumber();\n const highestInFile = numbersInFile.size > 0 ? Math.max(...numbersInFile.keys()) : 0;\n report.nextCustomerNumber = options.dryRun ? Math.max(stored, highestInFile + 1) : stored;\n return report;\n}\n"],"mappings":"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","names":["sql","sql"]}
1
+ {"version":3,"sources":["../src/fields.ts","../src/db.ts","../src/migrate.ts","../src/migrations/index.ts","../src/contacts.ts","../src/uuid.ts","../src/import.ts"],"sourcesContent":["import type { ColumnType } from '@marlinjai/data-table-core';\nimport { ContactError, FIELD_TYPES, type FieldType, type FieldValue, type FieldValues } from './model.js';\n\n/**\n * Custom field definitions and the checks that every stored value must pass.\n *\n * The type names are the data table's column type names (`ColumnType` in\n * @marlinjai/data-table-core), which export no value validation, so the rules\n * live here. A value is either accepted exactly as given, or refused with a\n * typed error that names the field. Nothing is coerced: a number is never read\n * from a string, and a date must be a real calendar day.\n */\n\nexport const FIELD_KEY = /^[a-z][a-z0-9_]{1,39}$/;\nexport const FIELD_LABEL_MAX = 80;\nexport const FIELD_OPTION_MAX = 80;\nexport const FIELD_OPTIONS_MAX = 100;\nexport const FIELD_TEXT_MAX = 2000;\nexport const FIELD_URL_MAX = 2048;\n\nexport interface FieldDefinition {\n key: string;\n label: string;\n type: FieldType;\n /** Choices for select and multi_select; null for every other type. */\n options: string[] | null;\n archived: boolean;\n createdAt: Date;\n}\n\nexport interface FieldDefinitionInput {\n key: string;\n label: string;\n type: string;\n options?: readonly string[] | null;\n}\n\nexport interface NormalizedFieldDefinition {\n key: string;\n label: string;\n type: FieldType;\n options: string[] | null;\n}\n\n// Compile-time guard: every field type must be a column type of the data table. Erased at run time.\nconst fieldTypesAreColumnTypes: readonly ColumnType[] = FIELD_TYPES;\nvoid fieldTypesAreColumnTypes;\n\nconst collapse = (text: string) => text.replace(/\\s+/g, ' ').trim();\n\n/** Validates a definition before it is stored. Throws ContactError('invalid_field' | 'too_long'). */\nexport function normalizeFieldDefinition(input: FieldDefinitionInput): NormalizedFieldDefinition {\n const key = String(input.key ?? '');\n if (!FIELD_KEY.test(key)) throw new ContactError('invalid_field', { field: 'key' });\n\n const label = collapse(String(input.label ?? ''));\n if (!label) throw new ContactError('invalid_field', { field: 'label' });\n if (label.length > FIELD_LABEL_MAX) throw new ContactError('too_long', { field: 'label' });\n\n if (!(FIELD_TYPES as readonly string[]).includes(input.type)) {\n throw new ContactError('invalid_field', { field: 'type' });\n }\n const type = input.type as FieldType;\n\n const choosable = type === 'select' || type === 'multi_select';\n if (!choosable) {\n if (input.options !== undefined && input.options !== null) {\n throw new ContactError('invalid_field', { field: 'options' });\n }\n return { key, label, type, options: null };\n }\n\n if (!Array.isArray(input.options) || input.options.length === 0 || input.options.length > FIELD_OPTIONS_MAX) {\n throw new ContactError('invalid_field', { field: 'options' });\n }\n const options = input.options.map((o) => collapse(String(o)));\n for (const option of options) {\n if (!option) throw new ContactError('invalid_field', { field: 'options' });\n if (option.length > FIELD_OPTION_MAX) throw new ContactError('too_long', { field: 'options' });\n }\n if (new Set(options).size !== options.length) throw new ContactError('invalid_field', { field: 'options' });\n return { key, label, type, options };\n}\n\nconst ISO_DAY = /^(\\d{4})-(\\d{2})-(\\d{2})$/;\n\n/** Checks one value against its definition. Returns the value as stored. Throws ContactError. */\nexport function checkFieldValue(def: FieldDefinition, value: unknown): FieldValue {\n const refuse = () => new ContactError('invalid_value', { field: def.key });\n switch (def.type) {\n case 'text': {\n if (typeof value !== 'string' || value.length === 0 || value.length > FIELD_TEXT_MAX) throw refuse();\n if (value !== value.trim()) throw refuse();\n return value;\n }\n case 'number': {\n if (typeof value !== 'number' || !Number.isFinite(value)) throw refuse();\n return value;\n }\n case 'date': {\n if (typeof value !== 'string') throw refuse();\n const match = ISO_DAY.exec(value);\n if (!match) throw refuse();\n const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];\n const date = new Date(Date.UTC(year, month - 1, day));\n if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) throw refuse();\n return value;\n }\n case 'boolean': {\n if (typeof value !== 'boolean') throw refuse();\n return value;\n }\n case 'select': {\n if (typeof value !== 'string' || !def.options?.includes(value)) throw refuse();\n return value;\n }\n case 'multi_select': {\n if (!Array.isArray(value) || value.length === 0) throw refuse();\n if (!value.every((v) => typeof v === 'string' && def.options?.includes(v))) throw refuse();\n if (new Set(value).size !== value.length) throw refuse();\n return [...value];\n }\n case 'url': {\n if (typeof value !== 'string' || value.length > FIELD_URL_MAX) throw refuse();\n let parsed: URL;\n try {\n parsed = new URL(value);\n } catch {\n throw refuse();\n }\n if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw refuse();\n return value;\n }\n }\n}\n\n/**\n * Applies a patch of custom field values to a contact's current values.\n *\n * - A key without a definition is refused (`unknown_field`), never dropped.\n * - A key whose definition is archived is refused (`field_archived`) when a value\n * is set. Clearing it (null) is allowed, so an archived field can be emptied.\n * - A null value removes the key. Keys not in the patch are left as they are,\n * including values under archived definitions.\n */\nexport function applyCustomFieldPatch(\n current: FieldValues,\n patch: Record<string, unknown>,\n definitions: readonly FieldDefinition[],\n): FieldValues {\n const byKey = new Map(definitions.map((d) => [d.key, d]));\n const next: FieldValues = { ...current };\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue;\n const def = byKey.get(key);\n if (!def) throw new ContactError('unknown_field', { field: key });\n if (value === null) {\n delete next[key];\n continue;\n }\n if (def.archived) throw new ContactError('field_archived', { field: key });\n next[key] = checkFieldValue(def, value);\n }\n return next;\n}\n","import { Kysely, type ColumnType, type Expression, type Generated } from 'kysely';\nimport { PostgresJSDialect } from 'kysely-postgres-js';\nimport postgres, { type Sql } from 'postgres';\nimport { ContactsLayoutError, missingMigrations } from './migrate.js';\nimport type { MigrationSource } from './migrations/index.js';\n\ntype Timestamp = ColumnType<Date, Date | string | undefined, Date | string>;\n\nexport interface ContactsTable {\n id: string;\n tenant_id: string;\n kind: 'person' | 'organization';\n name: string;\n company_or_role: Generated<string>;\n organization_id: string | null;\n email: string | null;\n phone: string | null;\n note: string | null;\n legal_form: string | null;\n address_line1: string | null;\n address_line2: string | null;\n postal_code: string | null;\n city: string | null;\n country: string | null;\n vat_id: string | null;\n customer_number: number | null;\n preferred_contact: string | null;\n /** Written as a cast JSON expression (see columns in contacts.ts); read back as the parsed object. */\n custom_fields: ColumnType<Record<string, unknown>, Expression<unknown> | undefined, Expression<unknown>>;\n identity_key: string;\n version: Generated<number>;\n archived_at: ColumnType<Date | null, Date | string | null | undefined, Date | string | null>;\n created_at: Timestamp;\n updated_at: Timestamp;\n}\n\nexport interface ContactFieldDefinitionsTable {\n tenant_id: string;\n id: string;\n key: string;\n label: string;\n type: string;\n /** Written as a cast JSON expression; read back as the parsed array (null for non-choice types). */\n options: ColumnType<string[] | null, Expression<unknown> | null | undefined, Expression<unknown> | null>;\n archived_at: ColumnType<Date | null, Date | string | null | undefined, Date | string | null>;\n created_at: Timestamp;\n}\n\nexport interface TenantCountersTable {\n tenant_id: string;\n next_customer_number: Generated<number>;\n}\n\nexport interface Database {\n contacts: ContactsTable;\n contact_field_definitions: ContactFieldDefinitionsTable;\n tenant_counters: TenantCountersTable;\n}\n\n/**\n * One app's handle on the contacts database. Create it once per process.\n *\n * Nothing connects until the first query, so an app starts and runs while the\n * contacts database is unreachable; only the calls that need it fail.\n */\nexport interface ContactsDb {\n readonly sql: Sql;\n readonly db: Kysely<Database>;\n /**\n * Resolves once the database has every migration this build needs. Throws\n * ContactsLayoutError when it is behind. A database that is ahead is fine.\n * The positive answer is remembered; a failure is asked again next time.\n */\n ready(): Promise<void>;\n close(): Promise<void>;\n}\n\nexport interface ContactsDbOptions {\n /** Shown in the database's connection list and logs, e.g. \"receipts\". Required: it is how a write is attributed. */\n applicationName: string;\n /** Connections this process may hold. Keep it small: many apps share one small instance. Default 3. */\n max?: number;\n /** Seconds before an idle connection is closed. Default 20. */\n idleTimeoutSeconds?: number;\n /** Seconds to wait for a connection before failing. Default 10. */\n connectTimeoutSeconds?: number;\n /** Tests only: the migrations `ready()` checks for. */\n migrations?: readonly MigrationSource[];\n}\n\nexport function createContactsDb(connectionString: string, options: ContactsDbOptions): ContactsDb {\n if (!connectionString) throw new TypeError('createContactsDb: a connection string is required');\n if (!options?.applicationName) throw new TypeError('createContactsDb: applicationName is required');\n const sql = postgres(connectionString, {\n max: options.max ?? 3,\n idle_timeout: options.idleTimeoutSeconds ?? 20,\n connect_timeout: options.connectTimeoutSeconds ?? 10,\n connection: { application_name: `contacts:${options.applicationName}`.slice(0, 63) },\n onnotice: () => {},\n });\n const db = new Kysely<Database>({ dialect: new PostgresJSDialect({ postgres: sql }) });\n\n let checked: Promise<void> | null = null;\n const ready = (): Promise<void> => {\n if (!checked) {\n const attempt = missingMigrations(sql, options.migrations).then((missing) => {\n if (missing.length > 0) throw new ContactsLayoutError(missing);\n });\n checked = attempt;\n // A failure (database down, layout behind) is not remembered.\n attempt.catch(() => {\n if (checked === attempt) checked = null;\n });\n }\n return checked;\n };\n\n return {\n sql,\n db,\n ready,\n close: async () => {\n await db.destroy();\n await sql.end({ timeout: 5 });\n },\n };\n}\n","import { createHash } from 'node:crypto';\nimport type { Sql } from 'postgres';\nimport { MIGRATIONS, type MigrationSource } from './migrations/index.js';\n\n/**\n * The migrations runner (the mail service's runner, with the migrations in\n * code). Each app that uses the contacts database runs it at start, before it\n * serves.\n *\n * - Each migration applies in its own transaction together with its\n * `_migrations` row, so a failing one leaves nothing half-applied and the\n * next run resumes from it.\n * - Every transaction takes the same advisory lock first and re-checks what is\n * applied, so two apps starting at once apply each migration exactly once.\n * - An applied migration whose text has since changed stops the run: applied\n * migrations are history, and a change belongs in a new one.\n * - A migration the database has but this build does not know is what an app\n * on an older version of the package sees. Migrations are additive, so the\n * older code keeps working: it is reported, not fatal.\n */\n\n/** Arbitrary, fixed: \"lumitra contacts migrations\". */\nconst LOCK_KEY = 7_382_014_552;\nconst NAME_PATTERN = /^(\\d{4})_[a-z0-9_]+$/;\n\nexport type Migration = MigrationSource & { checksum: string };\n\nexport class MigrationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'MigrationError';\n }\n}\n\nexport function loadMigrations(sources: readonly MigrationSource[] = MIGRATIONS): Migration[] {\n const seen = new Set<string>();\n const out: Migration[] = [];\n for (const m of sources) {\n const match = NAME_PATTERN.exec(m.name);\n if (!match) throw new MigrationError(`${m.name}: migrations are named NNNN_lowercase_name`);\n if (seen.has(match[1]!)) throw new MigrationError(`${m.name}: two migrations share the number ${match[1]}`);\n seen.add(match[1]!);\n out.push({ ...m, checksum: createHash('sha256').update(m.sql).digest('hex') });\n }\n return out.sort((a, b) => a.name.localeCompare(b.name));\n}\n\nexport type MigrateResult = { applied: string[]; alreadyApplied: number; unknown: string[] };\n\nexport async function migrate(\n sql: Sql,\n options: { migrations?: readonly MigrationSource[]; log?: (line: string) => void } = {},\n): Promise<MigrateResult> {\n const log = options.log ?? ((line: string) => console.log(`[contacts-migrate] ${line}`));\n const migrations = loadMigrations(options.migrations);\n\n await sql.begin(async (tx) => {\n await tx`SELECT pg_advisory_xact_lock(${LOCK_KEY})`;\n await tx`\n CREATE TABLE IF NOT EXISTS _migrations (\n name text PRIMARY KEY,\n checksum text NOT NULL,\n applied_at timestamptz NOT NULL DEFAULT now()\n )`;\n });\n\n const recorded = await sql<{ name: string; checksum: string }[]>`SELECT name, checksum FROM _migrations`;\n const byName = new Map(recorded.map((r) => [r.name, r.checksum]));\n for (const m of migrations) {\n const checksum = byName.get(m.name);\n if (checksum !== undefined && checksum !== m.checksum) {\n throw new MigrationError(\n `${m.name} was changed after it was applied. Applied migrations are never edited; put the change in a new one.`,\n );\n }\n }\n const known = new Set(migrations.map((m) => m.name));\n const unknown = recorded.map((r) => r.name).filter((n) => !known.has(n));\n for (const name of unknown) {\n log(`note: ${name} is applied in the database but not part of this build (another app runs a newer package)`);\n }\n\n const applied: string[] = [];\n for (const m of migrations) {\n const didApply = await sql.begin(async (tx) => {\n await tx`SELECT pg_advisory_xact_lock(${LOCK_KEY})`;\n const done = await tx`SELECT 1 FROM _migrations WHERE name = ${m.name}`;\n if (done.length > 0) return false;\n await tx.unsafe(m.sql);\n await tx`INSERT INTO _migrations (name, checksum) VALUES (${m.name}, ${m.checksum})`;\n return true;\n });\n if (didApply) {\n applied.push(m.name);\n log(`applied ${m.name}`);\n }\n }\n const alreadyApplied = migrations.length - applied.length;\n log(applied.length === 0 ? `up to date (${alreadyApplied} applied)` : `done: ${applied.length} new, ${alreadyApplied} before`);\n return { applied, alreadyApplied, unknown };\n}\n\nexport class ContactsLayoutError extends Error {\n readonly missing: string[];\n constructor(missing: string[]) {\n super(\n `The contacts database is behind this version of @marlinjai/contacts-core: missing ${missing.join(', ')}. ` +\n 'Run contacts-migrate (or migrate()) before using it.',\n );\n this.name = 'ContactsLayoutError';\n this.missing = missing;\n }\n}\n\n/**\n * The names of the migrations this build needs and the database lacks. Empty\n * means usable. A database that is AHEAD (it has migrations this build does not\n * know) is fine, because migrations are additive.\n */\nexport async function missingMigrations(\n sql: Sql,\n sources: readonly MigrationSource[] = MIGRATIONS,\n): Promise<string[]> {\n const needed = loadMigrations(sources).map((m) => m.name);\n const table = await sql<{ present: boolean }[]>`SELECT to_regclass('_migrations') IS NOT NULL AS present`;\n if (!table[0]?.present) return needed;\n const rows = await sql<{ name: string }[]>`SELECT name FROM _migrations`;\n const have = new Set(rows.map((r) => r.name));\n return needed.filter((n) => !have.has(n));\n}\n","/**\n * The table changes of the contacts database, in order.\n *\n * They live in code, not in .sql files, so they travel inside the bundle of\n * whatever app imports this package (a Next.js standalone build copies no\n * loose files out of node_modules).\n *\n * RULES\n * - Additive only: add a table, a column or an index. Never rename or drop in\n * the same release that stops using something; several apps on different\n * versions of this package share this database. A removal ships only after\n * every app runs a version that no longer reads the thing.\n * - An applied migration is history. Never edit one; add a new one. The\n * runner refuses to continue when the text of an applied migration changed.\n */\n\nexport interface MigrationSource {\n /** `NNNN_lowercase_name`, unique number. */\n name: string;\n sql: string;\n}\n\nexport const MIGRATIONS: readonly MigrationSource[] = [\n {\n name: '0001_contacts',\n sql: `\nCREATE TABLE contacts (\n id uuid PRIMARY KEY,\n -- The auth-brain tenant (the company). Text, because auth-brain ids are\n -- opaque to this package.\n tenant_id text NOT NULL CHECK (char_length(tenant_id) BETWEEN 1 AND 64),\n kind text NOT NULL CHECK (kind IN ('person', 'organization')),\n name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 160),\n -- Persons only. Empty string, not NULL, so it can take part in the identity key.\n company_or_role text NOT NULL DEFAULT '' CHECK (char_length(company_or_role) <= 160),\n organization_id uuid,\n email text,\n phone text,\n note text,\n legal_form text,\n address_line1 text,\n address_line2 text,\n postal_code text,\n city text,\n country text CHECK (country IS NULL OR country ~ '^[A-Z]{2}$'),\n vat_id text,\n customer_number integer CHECK (customer_number IS NULL OR customer_number > 0),\n -- Written by the package (contactIdentityKey); the duplicate check.\n identity_key text NOT NULL,\n version integer NOT NULL DEFAULT 1,\n archived_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT contacts_person_fields CHECK (kind = 'person' OR (company_or_role = '' AND organization_id IS NULL)),\n CONSTRAINT contacts_organization_fields CHECK (kind = 'organization' OR legal_form IS NULL),\n -- Target of the composite foreign key below: a link can only point inside the same company.\n CONSTRAINT contacts_tenant_id_id UNIQUE (tenant_id, id),\n CONSTRAINT contacts_organization_fk FOREIGN KEY (tenant_id, organization_id)\n REFERENCES contacts (tenant_id, id) ON DELETE SET NULL (organization_id)\n);\n\nCREATE UNIQUE INDEX contacts_identity ON contacts (tenant_id, kind, identity_key);\nCREATE UNIQUE INDEX contacts_customer_number ON contacts (tenant_id, customer_number)\n WHERE customer_number IS NOT NULL;\nCREATE INDEX contacts_list ON contacts (tenant_id, name);\nCREATE INDEX contacts_organization ON contacts (tenant_id, organization_id)\n WHERE organization_id IS NOT NULL;\n\n-- One counter per company. A number is taken with a single UPDATE ... RETURNING\n-- in the transaction that sets it on the contact, so it is never issued twice.\nCREATE TABLE tenant_counters (\n tenant_id text PRIMARY KEY CHECK (char_length(tenant_id) BETWEEN 1 AND 64),\n next_customer_number integer NOT NULL DEFAULT 1 CHECK (next_customer_number > 0)\n);\n`,\n },\n {\n name: '0002_contact_fields',\n sql: `\n-- A preferred way to be contacted. Null means none was recorded.\nALTER TABLE contacts\n ADD COLUMN preferred_contact text CHECK (preferred_contact IN ('email', 'phone', 'post', 'none'));\n\n-- Values of the company's custom fields, keyed by field key. Checked against the\n-- definitions by the package on every write, so the column itself only insists on an object.\nALTER TABLE contacts\n ADD COLUMN custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(custom_fields) = 'object');\n\n-- One definition per field and company. Archived definitions stay, so the values\n-- stored under them keep their meaning; the package refuses new values for them.\nCREATE TABLE contact_field_definitions (\n tenant_id text NOT NULL CHECK (char_length(tenant_id) BETWEEN 1 AND 64),\n id uuid NOT NULL,\n key text NOT NULL CHECK (key ~ '^[a-z][a-z0-9_]{1,39}$'),\n label text NOT NULL CHECK (char_length(label) BETWEEN 1 AND 80),\n type text NOT NULL CHECK (type IN ('text', 'number', 'date', 'boolean', 'select', 'multi_select', 'url')),\n -- Choices for select and multi_select; absent for every other type.\n options jsonb CHECK (options IS NULL OR jsonb_typeof(options) = 'array'),\n archived_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n PRIMARY KEY (tenant_id, id),\n CONSTRAINT contact_field_definitions_key UNIQUE (tenant_id, key),\n CONSTRAINT contact_field_definitions_options\n CHECK ((type IN ('select', 'multi_select')) = (options IS NOT NULL))\n);\n`,\n },\n];\n","import { sql, type Selectable, type Transaction } from 'kysely';\nimport { applyCustomFieldPatch, normalizeFieldDefinition, type FieldDefinition, type FieldDefinitionInput } from './fields.js';\nimport type { ContactsDb, ContactsTable, Database } from './db.js';\nimport {\n ContactError,\n contactIdentityKey,\n isContactId,\n normalizeContactInput,\n type Contact,\n type ContactInput,\n type ContactKind,\n type FieldValues,\n type NormalizedContact,\n type PreferredContact,\n} from './model.js';\nimport { uuidv7 } from './uuid.js';\n\ntype Row = Selectable<ContactsTable>;\n\nfunction toContact(row: Row): Contact {\n return {\n id: row.id,\n kind: row.kind,\n name: row.name,\n companyOrRole: row.company_or_role,\n organizationId: row.organization_id,\n email: row.email,\n phone: row.phone,\n note: row.note,\n legalForm: row.legal_form,\n addressLine1: row.address_line1,\n addressLine2: row.address_line2,\n postalCode: row.postal_code,\n city: row.city,\n country: row.country,\n vatId: row.vat_id,\n customerNumber: row.customer_number,\n preferredContact: row.preferred_contact as PreferredContact | null,\n customFields: (row.custom_fields ?? {}) as FieldValues,\n version: row.version,\n archived: row.archived_at !== null,\n createdAt: row.created_at,\n updatedAt: row.updated_at,\n };\n}\n\nfunction toDefinition(row: {\n key: string;\n label: string;\n type: string;\n options: string[] | null;\n archived_at: Date | null;\n created_at: Date;\n}): FieldDefinition {\n return {\n key: row.key,\n label: row.label,\n type: row.type as FieldDefinition['type'],\n options: row.options,\n archived: row.archived_at !== null,\n createdAt: row.created_at,\n };\n}\n\nfunction columns(data: NormalizedContact, customFields: FieldValues) {\n return {\n name: data.name,\n company_or_role: data.companyOrRole,\n organization_id: data.organizationId,\n email: data.email,\n phone: data.phone,\n note: data.note,\n legal_form: data.legalForm,\n address_line1: data.addressLine1,\n address_line2: data.addressLine2,\n postal_code: data.postalCode,\n city: data.city,\n country: data.country,\n vat_id: data.vatId,\n preferred_contact: data.preferredContact,\n custom_fields: sql`${JSON.stringify(customFields)}::text::jsonb`,\n identity_key: contactIdentityKey(data.kind, data.name, data.companyOrRole),\n };\n}\n\n/** A unique-index violation on the identity key (Postgres error 23505). */\nfunction isIdentityConflict(e: unknown): boolean {\n const err = e as { code?: string; constraint_name?: string };\n return err?.code === '23505' && err.constraint_name === 'contacts_identity';\n}\n\nfunction isCustomerNumberConflict(e: unknown): boolean {\n const err = e as { code?: string; constraint_name?: string };\n return err?.code === '23505' && err.constraint_name === 'contacts_customer_number';\n}\n\nfunction escapeLike(text: string): string {\n return text.replace(/[\\\\%_]/g, (c) => `\\\\${c}`);\n}\n\nexport interface ListOptions {\n kind?: ContactKind;\n /** Archived contacts are left out unless asked for. */\n includeArchived?: boolean;\n /** Case-insensitive match on name, company or role, and email. */\n search?: string;\n limit?: number;\n}\n\nexport interface ContactExport {\n contact: Contact;\n /** For a person linked to an organization. */\n organization: Contact | null;\n /** For an organization: the persons linked to it. */\n members: Contact[];\n}\n\n/**\n * The contacts of ONE company. Every query this object makes carries that\n * company's id, so an id from another company behaves exactly like an id that\n * does not exist. There is deliberately no way to query without a company.\n */\nexport interface Contacts {\n readonly tenantId: string;\n /** Sorted by name, then company or role. */\n list(options?: ListOptions): Promise<Contact[]>;\n get(id: string): Promise<Contact | null>;\n /** The given ids that exist in THIS company (archived included). Unknown and foreign ids are simply absent. */\n getMany(ids: readonly string[]): Promise<Contact[]>;\n /** The contact that would make `input` a duplicate, if there is one. */\n findSame(input: Pick<ContactInput, 'kind' | 'name' | 'companyOrRole'>, exceptId?: string): Promise<Contact | null>;\n /**\n * Throws `duplicate` (carrying the existing contact) when the identity\n * already exists. `id` is for moving existing data in; leave it out otherwise.\n */\n create(input: ContactInput, options?: { id?: string }): Promise<Contact>;\n /**\n * Corrects a contact; the kind cannot change. With `expectedVersion`, a save\n * based on an older version throws `stale` (carrying the current record)\n * instead of overwriting someone else's change.\n */\n update(id: string, input: ContactInput, options?: { expectedVersion?: number }): Promise<Contact>;\n /** Hides the contact from pickers. Anything that already names it keeps it. */\n archive(id: string): Promise<Contact>;\n restore(id: string): Promise<Contact>;\n /**\n * Folds `loserId` into `winnerId` and deletes the loser: empty fields of the\n * winner are filled from the loser, persons linked to a losing organization\n * move to the winner, a customer number moves when only the loser has one.\n * The CALLER repoints its own rows from the loser's id to the winner's.\n */\n merge(loserId: string, winnerId: string): Promise<Contact>;\n /** Gives the contact the company's next customer number. Returns the existing one when it already has one. */\n assignCustomerNumber(id: string): Promise<Contact>;\n /**\n * Sets a specific, already issued number (importing existing customers) and\n * moves the counter past it. Throws `customer_number_conflict` when the\n * number belongs to someone else or the contact has a different one.\n */\n setCustomerNumber(id: string, customerNumber: number): Promise<Contact>;\n /** The number the next `assignCustomerNumber` would hand out. */\n peekNextCustomerNumber(): Promise<number>;\n /** Defines a custom field for this company. Its key is unique per company; a key of an archived field stays taken. */\n createField(input: FieldDefinitionInput): Promise<FieldDefinition>;\n /** The company's custom fields. Archived ones are left out unless asked for. */\n listFields(options?: { includeArchived?: boolean }): Promise<FieldDefinition[]>;\n /** Archives a custom field by key. Stored values stay; new values are refused. */\n archiveField(key: string): Promise<FieldDefinition>;\n /** Deletes one contact for good. Persons linked to an erased organization keep their record, unlinked. */\n erase(id: string): Promise<boolean>;\n /** Deletes every contact and the counter of this company. Safe to repeat. Returns the number of contacts removed. */\n eraseAll(): Promise<number>;\n /** Everything held about one contact. */\n exportContact(id: string): Promise<ContactExport | null>;\n}\n\nexport function contactsFor(handle: ContactsDb, tenantId: string): Contacts {\n if (typeof tenantId !== 'string' || tenantId.length < 1 || tenantId.length > 64) {\n throw new TypeError('contactsFor: tenantId must be a non-empty string of at most 64 characters');\n }\n const { db } = handle;\n\n const own = (trx: Transaction<Database> | typeof db = db) =>\n trx.selectFrom('contacts').selectAll().where('tenant_id', '=', tenantId);\n\n /** Every definition of this company, archived ones included: their stored values need them. */\n async function loadDefinitions(trx: Transaction<Database> | typeof db = db): Promise<FieldDefinition[]> {\n const rows = await trx\n .selectFrom('contact_field_definitions')\n .selectAll()\n .where('tenant_id', '=', tenantId)\n .orderBy('created_at')\n .orderBy('key')\n .execute();\n return rows.map(toDefinition);\n }\n\n async function findByIdentity(kind: ContactKind, name: string, companyOrRole: string, exceptId?: string) {\n let q = own()\n .where('kind', '=', kind)\n .where('identity_key', '=', contactIdentityKey(kind, name, companyOrRole));\n if (exceptId) q = q.where('id', '!=', exceptId);\n const row = await q.executeTakeFirst();\n return row ? toContact(row) : null;\n }\n\n /** A person's organization link must point at an organization of this company. */\n async function assertOrganization(organizationId: string | null) {\n if (organizationId === null) return;\n const row = await own().where('id', '=', organizationId).executeTakeFirst();\n if (!row || row.kind !== 'organization') throw new ContactError('invalid_organization');\n }\n\n async function lockOwn(trx: Transaction<Database>, id: string): Promise<Row> {\n if (!isContactId(id)) throw new ContactError('not_found');\n const row = await own(trx).where('id', '=', id).forUpdate().executeTakeFirst();\n if (!row) throw new ContactError('not_found');\n return row;\n }\n\n async function setArchived(id: string, archived: boolean): Promise<Contact> {\n await handle.ready();\n return db.transaction().execute(async (trx) => {\n const row = await lockOwn(trx, id);\n if ((row.archived_at !== null) === archived) return toContact(row);\n const updated = await trx\n .updateTable('contacts')\n .set({ archived_at: archived ? new Date() : null, version: sql`version + 1`, updated_at: new Date() })\n .where('tenant_id', '=', tenantId)\n .where('id', '=', id)\n .returningAll()\n .executeTakeFirstOrThrow();\n return toContact(updated);\n });\n }\n\n return {\n tenantId,\n\n async list(options = {}) {\n await handle.ready();\n let q = own();\n if (options.kind) q = q.where('kind', '=', options.kind);\n if (!options.includeArchived) q = q.where('archived_at', 'is', null);\n const term = options.search?.trim();\n if (term) {\n const pattern = `%${escapeLike(term)}%`;\n q = q.where((eb) =>\n eb.or([\n eb('name', 'ilike', pattern),\n eb('company_or_role', 'ilike', pattern),\n eb('email', 'ilike', pattern),\n ]),\n );\n }\n q = q.orderBy('name', 'asc').orderBy('company_or_role', 'asc').orderBy('id', 'asc');\n if (options.limit !== undefined) q = q.limit(Math.max(1, Math.min(options.limit, 5000)));\n return (await q.execute()).map(toContact);\n },\n\n async get(id) {\n if (!isContactId(id)) return null;\n await handle.ready();\n const row = await own().where('id', '=', id).executeTakeFirst();\n return row ? toContact(row) : null;\n },\n\n async getMany(ids) {\n const unique = [...new Set(ids)].filter(isContactId);\n if (unique.length === 0) return [];\n await handle.ready();\n return (await own().where('id', 'in', unique).execute()).map(toContact);\n },\n\n async findSame(input, exceptId) {\n const data = normalizeContactInput({ kind: input.kind, name: input.name, companyOrRole: input.companyOrRole });\n await handle.ready();\n return findByIdentity(data.kind, data.name, data.companyOrRole, exceptId);\n },\n\n async create(input, options = {}) {\n const data = normalizeContactInput(input);\n const id = options.id ?? uuidv7();\n if (!isContactId(id)) throw new ContactError('invalid_field', { field: 'id' });\n await handle.ready();\n await assertOrganization(data.organizationId);\n const customFields = input.customFields\n ? applyCustomFieldPatch({}, input.customFields, await loadDefinitions())\n : {};\n const inserted = await db\n .insertInto('contacts')\n .values({ id, tenant_id: tenantId, kind: data.kind, ...columns(data, customFields) })\n .onConflict((oc) => oc.columns(['tenant_id', 'kind', 'identity_key']).doNothing())\n .returningAll()\n .executeTakeFirst();\n if (!inserted) {\n const existing = await findByIdentity(data.kind, data.name, data.companyOrRole);\n throw new ContactError('duplicate', { existing: existing ?? undefined });\n }\n return toContact(inserted);\n },\n\n async update(id, input, options = {}) {\n await handle.ready();\n let data: NormalizedContact | undefined;\n try {\n return await db.transaction().execute(async (trx) => {\n const row = await lockOwn(trx, id);\n if (input.kind !== undefined && input.kind !== row.kind) {\n throw new ContactError('invalid_field', { field: 'kind' });\n }\n if (options.expectedVersion !== undefined && options.expectedVersion !== row.version) {\n throw new ContactError('stale', { existing: toContact(row) });\n }\n data = normalizeContactInput({ ...input, kind: row.kind });\n if (data.organizationId !== null) {\n const org = await own(trx).where('id', '=', data.organizationId).executeTakeFirst();\n if (!org || org.kind !== 'organization') throw new ContactError('invalid_organization');\n }\n const currentFields = (row.custom_fields ?? {}) as FieldValues;\n const customFields = input.customFields\n ? applyCustomFieldPatch(currentFields, input.customFields, await loadDefinitions(trx))\n : currentFields;\n const updated = await trx\n .updateTable('contacts')\n .set({ ...columns(data, customFields), version: sql`version + 1`, updated_at: new Date() })\n .where('tenant_id', '=', tenantId)\n .where('id', '=', id)\n .returningAll()\n .executeTakeFirstOrThrow();\n return toContact(updated);\n });\n } catch (e) {\n if (isIdentityConflict(e) && data) {\n const existing = await findByIdentity(data.kind, data.name, data.companyOrRole, id);\n throw new ContactError('duplicate', { existing: existing ?? undefined });\n }\n throw e;\n }\n },\n\n async createField(input: FieldDefinitionInput) {\n await handle.ready();\n const data = normalizeFieldDefinition(input);\n try {\n const row = await db\n .insertInto('contact_field_definitions')\n .values({\n tenant_id: tenantId,\n id: uuidv7(),\n key: data.key,\n label: data.label,\n type: data.type,\n options: data.options === null ? null : sql`${JSON.stringify(data.options)}::text::jsonb`,\n })\n .returningAll()\n .executeTakeFirstOrThrow();\n return toDefinition(row);\n } catch (e) {\n if ((e as { code?: string; constraint_name?: string }).constraint_name === 'contact_field_definitions_key') {\n throw new ContactError('duplicate_field', { field: data.key });\n }\n throw e;\n }\n },\n\n async listFields(options = {}) {\n await handle.ready();\n const definitions = await loadDefinitions();\n return options.includeArchived ? definitions : definitions.filter((d) => !d.archived);\n },\n\n /** Archives a field: its stored values stay, and new values under it are refused. Repeating it changes nothing. */\n async archiveField(key) {\n await handle.ready();\n const row = await db\n .selectFrom('contact_field_definitions')\n .selectAll()\n .where('tenant_id', '=', tenantId)\n .where('key', '=', key)\n .executeTakeFirst();\n if (!row) throw new ContactError('field_not_found', { field: key });\n if (row.archived_at !== null) return toDefinition(row);\n const updated = await db\n .updateTable('contact_field_definitions')\n .set({ archived_at: new Date() })\n .where('tenant_id', '=', tenantId)\n .where('key', '=', key)\n .returningAll()\n .executeTakeFirstOrThrow();\n return toDefinition(updated);\n },\n\n archive: (id) => setArchived(id, true),\n restore: (id) => setArchived(id, false),\n\n async merge(loserId, winnerId) {\n if (loserId === winnerId) throw new ContactError('invalid_field', { field: 'winnerId' });\n await handle.ready();\n return db.transaction().execute(async (trx) => {\n // Lock in id order, so two opposite merges cannot deadlock.\n const [firstId, secondId] = loserId < winnerId ? [loserId, winnerId] : [winnerId, loserId];\n const first = await lockOwn(trx, firstId);\n const second = await lockOwn(trx, secondId);\n const loser = first.id === loserId ? first : second;\n const winner = first.id === winnerId ? first : second;\n if (loser.kind !== winner.kind) throw new ContactError('merge_kind_mismatch');\n if (\n loser.customer_number !== null &&\n winner.customer_number !== null &&\n loser.customer_number !== winner.customer_number\n ) {\n throw new ContactError('customer_number_conflict');\n }\n if (loser.kind === 'organization') {\n await trx\n .updateTable('contacts')\n .set({ organization_id: winner.id, version: sql`version + 1`, updated_at: new Date() })\n .where('tenant_id', '=', tenantId)\n .where('organization_id', '=', loser.id)\n .execute();\n }\n // The loser goes first: its customer number must be free before the winner takes it.\n await trx.deleteFrom('contacts').where('tenant_id', '=', tenantId).where('id', '=', loser.id).execute();\n const updated = await trx\n .updateTable('contacts')\n .set({\n organization_id: winner.organization_id ?? loser.organization_id,\n email: winner.email ?? loser.email,\n phone: winner.phone ?? loser.phone,\n note: winner.note ?? loser.note,\n legal_form: winner.legal_form ?? loser.legal_form,\n address_line1: winner.address_line1 ?? loser.address_line1,\n address_line2: winner.address_line2 ?? loser.address_line2,\n postal_code: winner.postal_code ?? loser.postal_code,\n city: winner.city ?? loser.city,\n country: winner.country ?? loser.country,\n vat_id: winner.vat_id ?? loser.vat_id,\n customer_number: winner.customer_number ?? loser.customer_number,\n // A merge of an archived and an active contact leaves an active one.\n archived_at: winner.archived_at !== null && loser.archived_at !== null ? winner.archived_at : null,\n version: sql`version + 1`,\n updated_at: new Date(),\n })\n .where('tenant_id', '=', tenantId)\n .where('id', '=', winner.id)\n .returningAll()\n .executeTakeFirstOrThrow();\n return toContact(updated);\n });\n },\n\n async assignCustomerNumber(id) {\n await handle.ready();\n return db.transaction().execute(async (trx) => {\n const row = await lockOwn(trx, id);\n if (row.customer_number !== null) return toContact(row);\n const taken = await sql<{ n: number }>`\n INSERT INTO tenant_counters (tenant_id, next_customer_number) VALUES (${tenantId}, 2)\n ON CONFLICT (tenant_id) DO UPDATE SET next_customer_number = tenant_counters.next_customer_number + 1\n RETURNING next_customer_number - 1 AS n`.execute(trx);\n const n = taken.rows[0]!.n;\n const updated = await trx\n .updateTable('contacts')\n .set({ customer_number: n, version: sql`version + 1`, updated_at: new Date() })\n .where('tenant_id', '=', tenantId)\n .where('id', '=', id)\n .returningAll()\n .executeTakeFirstOrThrow();\n return toContact(updated);\n });\n },\n\n async setCustomerNumber(id, customerNumber) {\n if (!Number.isInteger(customerNumber) || customerNumber < 1) {\n throw new ContactError('invalid_field', { field: 'customerNumber' });\n }\n await handle.ready();\n try {\n return await db.transaction().execute(async (trx) => {\n const row = await lockOwn(trx, id);\n if (row.customer_number !== null && row.customer_number !== customerNumber) {\n throw new ContactError('customer_number_conflict');\n }\n await sql`\n INSERT INTO tenant_counters (tenant_id, next_customer_number) VALUES (${tenantId}, ${customerNumber + 1})\n ON CONFLICT (tenant_id) DO UPDATE\n SET next_customer_number = GREATEST(tenant_counters.next_customer_number, ${customerNumber + 1})`.execute(trx);\n if (row.customer_number === customerNumber) return toContact(row);\n const updated = await trx\n .updateTable('contacts')\n .set({ customer_number: customerNumber, version: sql`version + 1`, updated_at: new Date() })\n .where('tenant_id', '=', tenantId)\n .where('id', '=', id)\n .returningAll()\n .executeTakeFirstOrThrow();\n return toContact(updated);\n });\n } catch (e) {\n if (isCustomerNumberConflict(e)) throw new ContactError('customer_number_conflict');\n throw e;\n }\n },\n\n async peekNextCustomerNumber() {\n await handle.ready();\n const row = await db\n .selectFrom('tenant_counters')\n .select('next_customer_number')\n .where('tenant_id', '=', tenantId)\n .executeTakeFirst();\n return row?.next_customer_number ?? 1;\n },\n\n async erase(id) {\n if (!isContactId(id)) return false;\n await handle.ready();\n const result = await db\n .deleteFrom('contacts')\n .where('tenant_id', '=', tenantId)\n .where('id', '=', id)\n .executeTakeFirst();\n return Number(result.numDeletedRows) > 0;\n },\n\n async eraseAll() {\n await handle.ready();\n return db.transaction().execute(async (trx) => {\n const result = await trx.deleteFrom('contacts').where('tenant_id', '=', tenantId).executeTakeFirst();\n await trx.deleteFrom('tenant_counters').where('tenant_id', '=', tenantId).execute();\n return Number(result.numDeletedRows);\n });\n },\n\n async exportContact(id) {\n if (!isContactId(id)) return null;\n await handle.ready();\n const row = await own().where('id', '=', id).executeTakeFirst();\n if (!row) return null;\n const organization = row.organization_id\n ? await own().where('id', '=', row.organization_id).executeTakeFirst()\n : undefined;\n const members =\n row.kind === 'organization'\n ? await own().where('organization_id', '=', row.id).orderBy('name', 'asc').execute()\n : [];\n return {\n contact: toContact(row),\n organization: organization ? toContact(organization) : null,\n members: members.map(toContact),\n };\n },\n };\n}\n","import { randomBytes } from 'node:crypto';\n\n/**\n * A version 7 universally unique identifier (RFC 9562): 48 bits of Unix\n * milliseconds, then random bits. Sorts by creation time and needs no central\n * counter, so any app can create a contact id on its own.\n */\nexport function uuidv7(now: number = Date.now()): string {\n const bytes = randomBytes(16);\n bytes[0] = Math.floor(now / 2 ** 40) & 0xff;\n bytes[1] = Math.floor(now / 2 ** 32) & 0xff;\n bytes[2] = Math.floor(now / 2 ** 24) & 0xff;\n bytes[3] = Math.floor(now / 2 ** 16) & 0xff;\n bytes[4] = Math.floor(now / 2 ** 8) & 0xff;\n bytes[5] = now & 0xff;\n bytes[6] = (bytes[6]! & 0x0f) | 0x70;\n bytes[8] = (bytes[8]! & 0x3f) | 0x80;\n const hex = bytes.toString('hex');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n","import type { Contacts } from './contacts.js';\nimport { ContactError, normalizeContactInput, type ContactInput, type ContactKind } from './model.js';\n\n/**\n * Importing an existing customer list (for example the clients that already\n * carry a hand-assigned customer number).\n *\n * The file is personal data: it lives outside every repository, and nothing\n * here prints a name. Reports speak in row numbers and error codes only.\n */\n\nexport const IMPORT_COLUMNS = [\n 'customer_number',\n 'kind',\n 'name',\n 'company_or_role',\n 'legal_form',\n 'address_line1',\n 'address_line2',\n 'postal_code',\n 'city',\n 'country',\n 'vat_id',\n 'email',\n 'phone',\n 'note',\n] as const;\n\nexport type ImportRow = Partial<Record<(typeof IMPORT_COLUMNS)[number], string>>;\n\nexport class CsvError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'CsvError';\n }\n}\n\n/** A small RFC 4180 reader: comma separated, double quotes, doubled quote inside quotes, first line is the header. */\nexport function parseCsv(text: string): ImportRow[] {\n const records: string[][] = [];\n let field = '';\n let record: string[] = [];\n let quoted = false;\n const src = text.replace(/^/, '');\n for (let i = 0; i < src.length; i++) {\n const c = src[i]!;\n if (quoted) {\n if (c === '\"') {\n if (src[i + 1] === '\"') {\n field += '\"';\n i++;\n } else quoted = false;\n } else field += c;\n } else if (c === '\"') {\n if (field.length > 0) throw new CsvError(`line ${records.length + 1}: a quote inside an unquoted field`);\n quoted = true;\n } else if (c === ',') {\n record.push(field);\n field = '';\n } else if (c === '\\n' || c === '\\r') {\n if (c === '\\r' && src[i + 1] === '\\n') i++;\n record.push(field);\n field = '';\n records.push(record);\n record = [];\n } else field += c;\n }\n if (quoted) throw new CsvError('the file ends inside a quoted field');\n if (field.length > 0 || record.length > 0) {\n record.push(field);\n records.push(record);\n }\n const nonEmpty = records.filter((r) => r.some((f) => f.trim() !== ''));\n const header = nonEmpty.shift();\n if (!header) throw new CsvError('the file is empty');\n const names = header.map((h) => h.trim().toLowerCase());\n const allowed = new Set<string>(IMPORT_COLUMNS);\n for (const n of names) if (!allowed.has(n)) throw new CsvError(`unknown column \"${n}\"`);\n if (!names.includes('name')) throw new CsvError('the column \"name\" is required');\n if (new Set(names).size !== names.length) throw new CsvError('a column appears twice');\n return nonEmpty.map((r, index) => {\n if (r.length > names.length) throw new CsvError(`row ${index + 2}: more fields than columns`);\n const row: ImportRow = {};\n names.forEach((n, i) => {\n row[n as (typeof IMPORT_COLUMNS)[number]] = (r[i] ?? '').trim();\n });\n return row;\n });\n}\n\nexport type ImportOutcome = 'created' | 'numbered' | 'unchanged';\n\nexport interface ImportReport {\n rows: number;\n created: number;\n /** An existing contact that received its customer number. */\n numbered: number;\n unchanged: number;\n /** `row` is the line in the file (the header is line 1). */\n errors: { row: number; code: string; field?: string }[];\n nextCustomerNumber: number;\n}\n\nfunction toInput(row: ImportRow): { input: ContactInput; customerNumber: number | null } {\n const kindRaw = (row.kind ?? '').toLowerCase() || 'organization';\n if (kindRaw !== 'person' && kindRaw !== 'organization') throw new ContactError('invalid_field', { field: 'kind' });\n let customerNumber: number | null = null;\n if (row.customer_number) {\n if (!/^\\d{1,9}$/.test(row.customer_number)) throw new ContactError('invalid_field', { field: 'customer_number' });\n customerNumber = Number.parseInt(row.customer_number, 10);\n if (customerNumber < 1) throw new ContactError('invalid_field', { field: 'customer_number' });\n }\n return {\n customerNumber,\n input: {\n kind: kindRaw as ContactKind,\n name: row.name ?? '',\n companyOrRole: row.company_or_role,\n legalForm: row.legal_form,\n addressLine1: row.address_line1,\n addressLine2: row.address_line2,\n postalCode: row.postal_code,\n city: row.city,\n country: row.country,\n vatId: row.vat_id,\n email: row.email,\n phone: row.phone,\n note: row.note,\n },\n };\n}\n\n/**\n * Creates the contacts of `rows` and sets their customer numbers. Safe to run\n * again: a contact that exists (same kind, name and company) is not created a\n * second time and its fields are left alone; only a missing customer number is\n * set. With `dryRun` nothing is written and the report says what would happen.\n */\nexport async function importContacts(\n contacts: Contacts,\n rows: readonly ImportRow[],\n options: { dryRun?: boolean } = {},\n): Promise<ImportReport> {\n const report: ImportReport = {\n rows: rows.length,\n created: 0,\n numbered: 0,\n unchanged: 0,\n errors: [],\n nextCustomerNumber: 1,\n };\n const numbersInFile = new Map<number, number>();\n for (const [index, row] of rows.entries()) {\n const line = index + 2;\n try {\n const { input, customerNumber } = toInput(row);\n normalizeContactInput(input);\n if (customerNumber !== null) {\n const firstLine = numbersInFile.get(customerNumber);\n if (firstLine !== undefined) throw new ContactError('customer_number_conflict');\n numbersInFile.set(customerNumber, line);\n }\n const existing = await contacts.findSame(input);\n if (existing) {\n if (customerNumber === null || existing.customerNumber === customerNumber) {\n report.unchanged++;\n continue;\n }\n if (existing.customerNumber !== null) throw new ContactError('customer_number_conflict');\n if (!options.dryRun) await contacts.setCustomerNumber(existing.id, customerNumber);\n report.numbered++;\n continue;\n }\n if (!options.dryRun) {\n const created = await contacts.create(input);\n if (customerNumber !== null) {\n try {\n await contacts.setCustomerNumber(created.id, customerNumber);\n } catch (e) {\n // Do not leave a contact behind whose number could not be set.\n await contacts.erase(created.id);\n throw e;\n }\n }\n }\n report.created++;\n } catch (e) {\n if (e instanceof ContactError) report.errors.push({ row: line, code: e.code, field: e.field });\n else throw e;\n }\n }\n const stored = await contacts.peekNextCustomerNumber();\n const highestInFile = numbersInFile.size > 0 ? Math.max(...numbersInFile.keys()) : 0;\n report.nextCustomerNumber = options.dryRun ? Math.max(stored, highestInFile + 1) : stored;\n return report;\n}\n"],"mappings":"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","names":["sql","sql"]}
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  migrate
4
- } from "./chunk-OHFPW5YM.js";
4
+ } from "./chunk-B2NV5TFC.js";
5
5
 
6
6
  // src/cli/migrate.ts
7
7
  import postgres from "postgres";
package/dist/model.cjs CHANGED
@@ -25,7 +25,9 @@ __export(model_exports, {
25
25
  CONTACT_NAME_MAX: () => CONTACT_NAME_MAX,
26
26
  CONTACT_NOTE_MAX: () => CONTACT_NOTE_MAX,
27
27
  ContactError: () => ContactError,
28
+ FIELD_TYPES: () => FIELD_TYPES,
28
29
  ORGANIZATION_NAME_MAX: () => ORGANIZATION_NAME_MAX,
30
+ PREFERRED_CONTACT: () => PREFERRED_CONTACT,
29
31
  contactIdentityKey: () => contactIdentityKey,
30
32
  formatCustomerNumber: () => formatCustomerNumber,
31
33
  formatGuest: () => formatGuest,
@@ -33,6 +35,8 @@ __export(model_exports, {
33
35
  normalizeContactInput: () => normalizeContactInput
34
36
  });
35
37
  module.exports = __toCommonJS(model_exports);
38
+ var PREFERRED_CONTACT = ["email", "phone", "post", "none"];
39
+ var FIELD_TYPES = ["text", "number", "date", "boolean", "select", "multi_select", "url"];
36
40
  var ContactError = class extends Error {
37
41
  code;
38
42
  /** For `duplicate`: the contact that already carries this identity. For `stale`: the current record. */
@@ -111,9 +115,15 @@ function normalizeContactInput(input) {
111
115
  postalCode: optional(input.postalCode, "postalCode", 20),
112
116
  city: optional(input.city, "city", SHORT_FIELD_MAX),
113
117
  country,
114
- vatId: optional(input.vatId, "vatId", 40)
118
+ vatId: optional(input.vatId, "vatId", 40),
119
+ preferredContact: preferredContact(input.preferredContact)
115
120
  };
116
121
  }
122
+ function preferredContact(value) {
123
+ if (value === null || value === void 0) return null;
124
+ if (PREFERRED_CONTACT.includes(value)) return value;
125
+ throw new ContactError("invalid_field", { field: "preferredContact" });
126
+ }
117
127
  function contactIdentityKey(kind, name, companyOrRole = "") {
118
128
  const n = collapse(name).toLocaleLowerCase("de-DE");
119
129
  if (kind === "organization") return n;
@@ -132,7 +142,9 @@ function formatCustomerNumber(n, width = 4) {
132
142
  CONTACT_NAME_MAX,
133
143
  CONTACT_NOTE_MAX,
134
144
  ContactError,
145
+ FIELD_TYPES,
135
146
  ORGANIZATION_NAME_MAX,
147
+ PREFERRED_CONTACT,
136
148
  contactIdentityKey,
137
149
  formatCustomerNumber,
138
150
  formatGuest,
@@ -1 +1 @@
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":[]}
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\n/** How a contact prefers to be reached. Null when none was recorded. */\nexport const PREFERRED_CONTACT = ['email', 'phone', 'post', 'none'] as const;\nexport type PreferredContact = (typeof PREFERRED_CONTACT)[number];\n\n/** The field types of a custom field: the data table's column type names (see fields.ts). */\nexport const FIELD_TYPES = ['text', 'number', 'date', 'boolean', 'select', 'multi_select', 'url'] as const;\nexport type FieldType = (typeof FIELD_TYPES)[number];\n\n/** One stored value of a custom field. Shapes are checked by fields.ts against the field's type. */\nexport type FieldValue = string | number | boolean | string[];\nexport type FieldValues = Record<string, FieldValue>;\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 preferredContact: PreferredContact | null;\n /** The company's custom fields set on this contact, keyed by field key. */\n customFields: FieldValues;\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 preferredContact?: string | null;\n /**\n * Field values to set, keyed by field key. A null value removes that field's value.\n * Keys not given are left as they are. Checked against the company's definitions.\n */\n customFields?: Record<string, unknown>;\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 | 'unknown_field'\n | 'field_archived'\n | 'invalid_value'\n | 'duplicate_field'\n | 'field_not_found';\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 preferredContact: PreferredContact | 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 preferredContact: preferredContact(input.preferredContact),\n };\n}\n\nfunction preferredContact(value: string | null | undefined): PreferredContact | null {\n if (value === null || value === undefined) return null;\n if ((PREFERRED_CONTACT as readonly string[]).includes(value)) return value as PreferredContact;\n throw new ContactError('invalid_field', { field: 'preferredContact' });\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":[]}
package/dist/model.d.cts CHANGED
@@ -5,6 +5,15 @@
5
5
  * anywhere, including browser code, through `@marlinjai/contacts-core/model`.
6
6
  */
7
7
  type ContactKind = 'person' | 'organization';
8
+ /** How a contact prefers to be reached. Null when none was recorded. */
9
+ declare const PREFERRED_CONTACT: readonly ["email", "phone", "post", "none"];
10
+ type PreferredContact = (typeof PREFERRED_CONTACT)[number];
11
+ /** The field types of a custom field: the data table's column type names (see fields.ts). */
12
+ declare const FIELD_TYPES: readonly ["text", "number", "date", "boolean", "select", "multi_select", "url"];
13
+ type FieldType = (typeof FIELD_TYPES)[number];
14
+ /** One stored value of a custom field. Shapes are checked by fields.ts against the field's type. */
15
+ type FieldValue = string | number | boolean | string[];
16
+ type FieldValues = Record<string, FieldValue>;
8
17
  interface Contact {
9
18
  /** Stable id. Never reused, never derived from the name. */
10
19
  id: string;
@@ -28,6 +37,9 @@ interface Contact {
28
37
  vatId: string | null;
29
38
  /** Unique per company once assigned; never reused. */
30
39
  customerNumber: number | null;
40
+ preferredContact: PreferredContact | null;
41
+ /** The company's custom fields set on this contact, keyed by field key. */
42
+ customFields: FieldValues;
31
43
  /** Rises by one with every change; pass it back as `expectedVersion` to refuse a stale save. */
32
44
  version: number;
33
45
  archived: boolean;
@@ -49,8 +61,14 @@ interface ContactInput {
49
61
  city?: string | null;
50
62
  country?: string | null;
51
63
  vatId?: string | null;
64
+ preferredContact?: string | null;
65
+ /**
66
+ * Field values to set, keyed by field key. A null value removes that field's value.
67
+ * Keys not given are left as they are. Checked against the company's definitions.
68
+ */
69
+ customFields?: Record<string, unknown>;
52
70
  }
53
- type ContactErrorCode = 'invalid_name' | 'invalid_field' | 'too_long' | 'duplicate' | 'not_found' | 'stale' | 'invalid_organization' | 'customer_number_conflict' | 'merge_kind_mismatch';
71
+ type ContactErrorCode = 'invalid_name' | 'invalid_field' | 'too_long' | 'duplicate' | 'not_found' | 'stale' | 'invalid_organization' | 'customer_number_conflict' | 'merge_kind_mismatch' | 'unknown_field' | 'field_archived' | 'invalid_value' | 'duplicate_field' | 'field_not_found';
54
72
  declare class ContactError extends Error {
55
73
  readonly code: ContactErrorCode;
56
74
  /** For `duplicate`: the contact that already carries this identity. For `stale`: the current record. */
@@ -82,6 +100,7 @@ interface NormalizedContact {
82
100
  city: string | null;
83
101
  country: string | null;
84
102
  vatId: string | null;
103
+ preferredContact: PreferredContact | null;
85
104
  }
86
105
  declare function isContactId(value: unknown): value is string;
87
106
  /**
@@ -101,4 +120,4 @@ declare function formatGuest(name: string, company: string): string;
101
120
  /** "0025" for 25. Padding is presentation; the stored number is an integer. */
102
121
  declare function formatCustomerNumber(n: number, width?: number): string;
103
122
 
104
- export { CONTACT_COMPANY_MAX, CONTACT_EMAIL_MAX, CONTACT_NAME_MAX, CONTACT_NOTE_MAX, type Contact, ContactError, type ContactErrorCode, type ContactInput, type ContactKind, type NormalizedContact, ORGANIZATION_NAME_MAX, contactIdentityKey, formatCustomerNumber, formatGuest, isContactId, normalizeContactInput };
123
+ export { CONTACT_COMPANY_MAX, CONTACT_EMAIL_MAX, CONTACT_NAME_MAX, CONTACT_NOTE_MAX, type Contact, ContactError, type ContactErrorCode, type ContactInput, type ContactKind, FIELD_TYPES, type FieldType, type FieldValue, type FieldValues, type NormalizedContact, ORGANIZATION_NAME_MAX, PREFERRED_CONTACT, type PreferredContact, contactIdentityKey, formatCustomerNumber, formatGuest, isContactId, normalizeContactInput };
package/dist/model.d.ts CHANGED
@@ -5,6 +5,15 @@
5
5
  * anywhere, including browser code, through `@marlinjai/contacts-core/model`.
6
6
  */
7
7
  type ContactKind = 'person' | 'organization';
8
+ /** How a contact prefers to be reached. Null when none was recorded. */
9
+ declare const PREFERRED_CONTACT: readonly ["email", "phone", "post", "none"];
10
+ type PreferredContact = (typeof PREFERRED_CONTACT)[number];
11
+ /** The field types of a custom field: the data table's column type names (see fields.ts). */
12
+ declare const FIELD_TYPES: readonly ["text", "number", "date", "boolean", "select", "multi_select", "url"];
13
+ type FieldType = (typeof FIELD_TYPES)[number];
14
+ /** One stored value of a custom field. Shapes are checked by fields.ts against the field's type. */
15
+ type FieldValue = string | number | boolean | string[];
16
+ type FieldValues = Record<string, FieldValue>;
8
17
  interface Contact {
9
18
  /** Stable id. Never reused, never derived from the name. */
10
19
  id: string;
@@ -28,6 +37,9 @@ interface Contact {
28
37
  vatId: string | null;
29
38
  /** Unique per company once assigned; never reused. */
30
39
  customerNumber: number | null;
40
+ preferredContact: PreferredContact | null;
41
+ /** The company's custom fields set on this contact, keyed by field key. */
42
+ customFields: FieldValues;
31
43
  /** Rises by one with every change; pass it back as `expectedVersion` to refuse a stale save. */
32
44
  version: number;
33
45
  archived: boolean;
@@ -49,8 +61,14 @@ interface ContactInput {
49
61
  city?: string | null;
50
62
  country?: string | null;
51
63
  vatId?: string | null;
64
+ preferredContact?: string | null;
65
+ /**
66
+ * Field values to set, keyed by field key. A null value removes that field's value.
67
+ * Keys not given are left as they are. Checked against the company's definitions.
68
+ */
69
+ customFields?: Record<string, unknown>;
52
70
  }
53
- type ContactErrorCode = 'invalid_name' | 'invalid_field' | 'too_long' | 'duplicate' | 'not_found' | 'stale' | 'invalid_organization' | 'customer_number_conflict' | 'merge_kind_mismatch';
71
+ type ContactErrorCode = 'invalid_name' | 'invalid_field' | 'too_long' | 'duplicate' | 'not_found' | 'stale' | 'invalid_organization' | 'customer_number_conflict' | 'merge_kind_mismatch' | 'unknown_field' | 'field_archived' | 'invalid_value' | 'duplicate_field' | 'field_not_found';
54
72
  declare class ContactError extends Error {
55
73
  readonly code: ContactErrorCode;
56
74
  /** For `duplicate`: the contact that already carries this identity. For `stale`: the current record. */
@@ -82,6 +100,7 @@ interface NormalizedContact {
82
100
  city: string | null;
83
101
  country: string | null;
84
102
  vatId: string | null;
103
+ preferredContact: PreferredContact | null;
85
104
  }
86
105
  declare function isContactId(value: unknown): value is string;
87
106
  /**
@@ -101,4 +120,4 @@ declare function formatGuest(name: string, company: string): string;
101
120
  /** "0025" for 25. Padding is presentation; the stored number is an integer. */
102
121
  declare function formatCustomerNumber(n: number, width?: number): string;
103
122
 
104
- export { CONTACT_COMPANY_MAX, CONTACT_EMAIL_MAX, CONTACT_NAME_MAX, CONTACT_NOTE_MAX, type Contact, ContactError, type ContactErrorCode, type ContactInput, type ContactKind, type NormalizedContact, ORGANIZATION_NAME_MAX, contactIdentityKey, formatCustomerNumber, formatGuest, isContactId, normalizeContactInput };
123
+ export { CONTACT_COMPANY_MAX, CONTACT_EMAIL_MAX, CONTACT_NAME_MAX, CONTACT_NOTE_MAX, type Contact, ContactError, type ContactErrorCode, type ContactInput, type ContactKind, FIELD_TYPES, type FieldType, type FieldValue, type FieldValues, type NormalizedContact, ORGANIZATION_NAME_MAX, PREFERRED_CONTACT, type PreferredContact, contactIdentityKey, formatCustomerNumber, formatGuest, isContactId, normalizeContactInput };
package/dist/model.js CHANGED
@@ -4,20 +4,24 @@ 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";
14
16
  export {
15
17
  CONTACT_COMPANY_MAX,
16
18
  CONTACT_EMAIL_MAX,
17
19
  CONTACT_NAME_MAX,
18
20
  CONTACT_NOTE_MAX,
19
21
  ContactError,
22
+ FIELD_TYPES,
20
23
  ORGANIZATION_NAME_MAX,
24
+ PREFERRED_CONTACT,
21
25
  contactIdentityKey,
22
26
  formatCustomerNumber,
23
27
  formatGuest,