You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/repos-clients.ts

218 lines
9.9 KiB
TypeScript

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
/** 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
db_password_enc: string | null
source: string; created_at: string
}
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 } : {}),
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 } = {},
): Promise<Client[]> {
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}%`)
}
const rows = await db.all<ClientRow>(`SELECT * FROM client${where} ORDER BY name`, ...args)
return rows.map(toClient)
}
export async function getClient(db: DB, id: string): Promise<Client | null> {
const row = await db.get<ClientRow>(`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
}
export async function createClient(db: DB, userId: string, input: ClientInput): Promise<Client> {
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, 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.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
}
export async function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Promise<Client> {
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) }
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<Client> {
return db.transaction(async () => {
const before = await getClient(db, id)
if (before === null) throw new Error('Client not found')
if (plain !== '' && keyHex === '') {
throw new Error(`HQ_SECRET_KEY is not configured — refusing to store a DB password unencrypted`)
}
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<string> {
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')
if (keyHex === '') throw new Error(`HQ_SECRET_KEY is not configured — cannot decrypt`)
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<Client> {
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
})
}