import { uuidv7, validateGstin } from '@sims/domain' import { writeAudit } from './audit' import { decrypt, encrypt } from './crypto' import type { DB } from './db' /** Client registry — plain functions over the handle (D12 portable-repo pattern). */ export interface ClientContact { name: string; phone?: string; email?: string; role?: string } export interface Client { id: string; code: string; name: string; gstin?: string; stateCode: string address: string; contacts: ClientContact[] status: 'lead' | 'active' | 'dormant' | 'lost'; notes: string /** Account/enquiry owner (→ staff_user.id); absent = unassigned. Routes leads (D-EMP A4). */ ownerId?: string /** D18 WS-F support-access data (APEX parity). The DB password is NOT here — * it is encrypted at rest and readable only via revealClientDbPassword (audited). */ anydesk?: string; os?: string; district?: string; sector?: string /** D35: 'PACS' (co-op society) or 'Store' (retail). */ clientType: string /** D37: society category (Service Bank / Vanitha / Housing / Employees / …). */ category: string /** True when an encrypted DB password is stored (the UI shows •••• + Reveal). */ hasDbPassword: boolean } interface ClientRow { id: string; code: string; name: string; gstin: string | null; state_code: string address: string; contacts: string; status: string; owner_id: string | null; notes: string anydesk: string | null; os: string | null; district: string | null; sector: string | null client_type: string | null; category: string | null db_password_enc: string | null source: string; created_at: string } /** Derive the society category from a name (same taxonomy as the DB backfill; most specific first). */ export function categoryFromName(name: string): string { const n = name.toUpperCase() if (/STORE[S]?$/.test(name.trim().toUpperCase())) return 'Store' if (n.includes('VANITHA')) return 'Vanitha' if (n.includes('HOUSING')) return 'Housing' if (n.includes('URBAN')) return 'Urban Bank' if (/EMPLOYEE|TEACHER|STAFF/.test(n)) return 'Employees' if (n.includes('MARKETING')) return 'Marketing' if (/AGRICULTUR|RURAL DEVELOPMENT|RUBBER/.test(n)) return 'Agricultural' if (/SERVICE CO-OPERATIVE|SERVICE BANK|SCB/.test(n)) return 'Service Bank' if (n.includes('BANK')) return 'Bank (other)' if (/SOCIET|CO-OPERATIVE|AICOS/.test(n)) return 'Society (other)' return 'Other' } function toClient(r: ClientRow): Client { return { id: r.id, code: r.code, name: r.name, ...(r.gstin !== null ? { gstin: r.gstin } : {}), stateCode: r.state_code, address: r.address, contacts: JSON.parse(r.contacts) as ClientContact[], status: r.status as Client['status'], notes: r.notes, ...(r.owner_id !== null ? { ownerId: r.owner_id } : {}), ...(r.anydesk !== null ? { anydesk: r.anydesk } : {}), ...(r.os !== null ? { os: r.os } : {}), ...(r.district !== null ? { district: r.district } : {}), ...(r.sector !== null ? { sector: r.sector } : {}), clientType: r.client_type ?? 'PACS', category: r.category ?? 'Other', hasDbPassword: r.db_password_enc !== null, // the ciphertext itself never leaves the repo } } function assertGstin(gstin: string): void { const v = validateGstin(gstin) if (!v.ok) throw new Error(`Invalid GSTIN (${v.reason ?? 'invalid'}): ${gstin}`) } export async function listClients( db: DB, q?: string, filters: { district?: string; sector?: string; clientType?: string; category?: string } = {}, ): Promise { let where = ' WHERE 1=1' const args: unknown[] = [] // D24 (red-team): LOWER(...) LIKE LOWER(?) — bare LIKE is case-INsensitive on SQLite // but case-SENSITIVE on Postgres, so search silently broke on the production engine. // LOWER on both sides matches on both engines. if (q !== undefined && q !== '') { const like = `%${q}%` where += ` AND (LOWER(name) LIKE LOWER(?) OR LOWER(code) LIKE LOWER(?) OR LOWER(gstin) LIKE LOWER(?))` args.push(like, like, like) } // D18 WS-F: the old book's two working filters — LIKE, so a partial "Ern" // already narrows to Ernakulam while the user types. if (filters.district !== undefined && filters.district !== '') { where += ` AND LOWER(district) LIKE LOWER(?)`; args.push(`%${filters.district}%`) } if (filters.sector !== undefined && filters.sector !== '') { where += ` AND LOWER(sector) LIKE LOWER(?)`; args.push(`%${filters.sector}%`) } if (filters.clientType !== undefined && filters.clientType !== '') { where += ` AND client_type=?`; args.push(filters.clientType) } if (filters.category !== undefined && filters.category !== '') { where += ` AND category=?`; args.push(filters.category) } const rows = await db.all(`SELECT * FROM client${where} ORDER BY name`, ...args) return rows.map(toClient) } export async function getClient(db: DB, id: string): Promise { const row = await db.get(`SELECT * FROM client WHERE id=?`, id) return row === undefined ? null : toClient(row) } export interface ClientInput { code?: string; name: string; gstin?: string; stateCode: string; address?: string contacts?: ClientContact[]; status?: Client['status']; notes?: string anydesk?: string; os?: string; district?: string; sector?: string; clientType?: string; category?: string } /** New clients default their type from the name (…STORE → Store), like the backfill. */ function typeFromName(name: string): string { return /store[s]?$/i.test(name.trim()) ? 'Store' : 'PACS' } export async function createClient(db: DB, userId: string, input: ClientInput): Promise { if (input.gstin !== undefined) assertGstin(input.gstin) const id = uuidv7() const n = ((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM client`))!).n const code = input.code ?? `CL${String(n + 1).padStart(4, '0')}` await db.run( `INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status, anydesk, os, district, sector, client_type, category, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, code, input.name, input.gstin ?? null, input.stateCode, input.address ?? '', JSON.stringify(input.contacts ?? []), input.status ?? 'active', input.anydesk ?? null, input.os ?? null, input.district ?? null, input.sector ?? null, input.clientType ?? typeFromName(input.name), input.category ?? categoryFromName(input.name), input.notes ?? '', new Date().toISOString(), ) const client = (await getClient(db, id))! await writeAudit(db, userId, 'create', 'client', id, undefined, client) return client } export interface ClientPatch { name?: string; gstin?: string | null; stateCode?: string; address?: string contacts?: ClientContact[]; status?: Client['status']; notes?: string /** '' clears the field (stored NULL). */ anydesk?: string; os?: string; district?: string; sector?: string clientType?: string; category?: string } export async function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Promise { const before = await getClient(db, id) if (before === null) throw new Error('Client not found') if (patch.gstin !== undefined && patch.gstin !== null) assertGstin(patch.gstin) const sets: string[] = [] const args: unknown[] = [] if (patch.name !== undefined) { sets.push('name=?'); args.push(patch.name) } if (patch.gstin !== undefined) { sets.push('gstin=?'); args.push(patch.gstin) } if (patch.stateCode !== undefined) { sets.push('state_code=?'); args.push(patch.stateCode) } if (patch.address !== undefined) { sets.push('address=?'); args.push(patch.address) } if (patch.contacts !== undefined) { sets.push('contacts=?'); args.push(JSON.stringify(patch.contacts)) } if (patch.status !== undefined) { sets.push('status=?'); args.push(patch.status) } if (patch.notes !== undefined) { sets.push('notes=?'); args.push(patch.notes) } if (patch.clientType !== undefined) { sets.push('client_type=?'); args.push(patch.clientType) } if (patch.category !== undefined) { sets.push('category=?'); args.push(patch.category) } const optText = (col: string, v: string | undefined) => { if (v !== undefined) { sets.push(`${col}=?`); args.push(v.trim() === '' ? null : v.trim()) } } optText('anydesk', patch.anydesk) optText('os', patch.os) optText('district', patch.district) optText('sector', patch.sector) if (sets.length > 0) { args.push(id) await db.run(`UPDATE client SET ${sets.join(', ')} WHERE id=?`, ...args) } const after = (await getClient(db, id))! await writeAudit(db, userId, 'update', 'client', id, before, after) return after } /** * Store (or clear, with '') the client's DB password — AES-256-GCM at rest (D18 WS-F). * Deliberately NOT part of ClientPatch: writing a support credential is its own * owner/manager-gated, audited action, and the value never appears in audit rows. * An empty keyHex fails loudly — never store a support credential unencrypted. */ export async function setClientDbPassword( db: DB, userId: string, id: string, plain: string, keyHex: string, ): Promise { return db.transaction(async () => { const before = await getClient(db, id) if (before === null) throw new Error('Client not found') // D31: stored in the clear (encrypt() tags plaintext); no key required. const enc = plain === '' ? null : encrypt(plain, keyHex) await db.run(`UPDATE client SET db_password_enc=? WHERE id=?`, enc, id) await writeAudit(db, userId, 'set_db_password', 'client', id, undefined, { set: enc !== null }) return (await getClient(db, id))! }) } /** Decrypt-and-return the stored DB password — every reveal writes an audit row. */ export async function revealClientDbPassword(db: DB, userId: string, id: string, keyHex: string): Promise { const row = await db.get<{ db_password_enc: string | null }>( `SELECT db_password_enc FROM client WHERE id=?`, id) if (row === undefined) throw new Error('Client not found') if (row.db_password_enc === null) throw new Error('No DB password stored for this client') // D31: plaintext returns without a key; a legacy row still needs the key (decrypt throws). const plain = decrypt(row.db_password_enc, keyHex) await writeAudit(db, userId, 'reveal_db_password', 'client', id) return plain } /** * Bulk-apply the same patch to many clients (D26) — for cleanup like filling GSTIN / * state / sector across a set. One transaction; each client keeps its own audited * before/after via updateClient (traceable). Returns how many were updated. */ export async function bulkUpdateClients( db: DB, userId: string, ids: string[], patch: ClientPatch, ): Promise<{ updated: number }> { if (ids.length === 0) return { updated: 0 } return db.transaction(async () => { let updated = 0 for (const id of ids) { if ((await getClient(db, id)) === null) continue await updateClient(db, userId, id, patch) updated++ } return { updated } }) } /** * Assign (or clear, with null) the account/enquiry owner. Deliberately NOT part of * updateClient/ClientPatch: the generic PATCH /clients/:id is open to staff, while * ownership routing is owner/manager-gated and audited as its own action. */ export async function setClientOwner(db: DB, userId: string, id: string, ownerId: string | null): Promise { return db.transaction(async () => { const before = await getClient(db, id) if (before === null) throw new Error('Client not found') if (ownerId !== null) { const emp = await db.get<{ active: number }>(`SELECT active FROM staff_user WHERE id=?`, ownerId) if (emp === undefined) throw new Error('Employee not found') if (emp.active !== 1) throw new Error('Cannot assign an inactive employee as account owner') } await db.run(`UPDATE client SET owner_id=? WHERE id=?`, ownerId, id) const after = (await getClient(db, id))! await writeAudit(db, userId, 'set_owner', 'client', id, before, after) return after }) }