From 9e4d53fb65e2ec14bac49a39a7bfacab427d18cf Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 00:55:05 +0530 Subject: [PATCH] feat(hq): client registry with GSTIN validation (HQ-1 task 4) Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 42 ++++++++++++++- apps/hq/src/repos-clients.ts | 102 +++++++++++++++++++++++++++++++++++ apps/hq/test/clients.test.ts | 22 ++++++++ 3 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 apps/hq/src/repos-clients.ts create mode 100644 apps/hq/test/clients.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 73d0083..aa149e0 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -1,6 +1,12 @@ -import { Router } from 'express' -import { login } from './auth' +import { Router, type Response } from 'express' +import { login, requireAuth } from './auth' import type { DB } from './db' +import { + createClient, getClient, listClients, updateClient, + type ClientInput, type ClientPatch, +} from './repos-clients' + +const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id export function apiRouter(db: DB): Router { const r = Router() @@ -13,5 +19,37 @@ export function apiRouter(db: DB): Router { if (!out) { res.status(401).json({ ok: false, error: 'Invalid email or password' }); return } res.json({ ok: true, ...out }) }) + + // ---------- clients ---------- + r.get('/clients', requireAuth, (req, res) => { + const q = typeof req.query['q'] === 'string' ? req.query['q'] : undefined + res.json({ ok: true, clients: listClients(db, q) }) + }) + r.post('/clients', requireAuth, (req, res) => { + try { + const client = createClient(db, staffId(res), req.body as ClientInput) + res.json({ ok: true, client }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.get('/clients/:id', requireAuth, (req, res) => { + const client = getClient(db, String(req.params['id'] ?? '')) + if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + res.json({ ok: true, client }) + }) + r.patch('/clients/:id', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClient(db, id) === null) { + res.status(404).json({ ok: false, error: 'Client not found' }); return + } + try { + const client = updateClient(db, staffId(res), id, req.body as ClientPatch) + res.json({ ok: true, client }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + return r } diff --git a/apps/hq/src/repos-clients.ts b/apps/hq/src/repos-clients.ts new file mode 100644 index 0000000..d843afd --- /dev/null +++ b/apps/hq/src/repos-clients.ts @@ -0,0 +1,102 @@ +import { uuidv7, validateGstin } from '@sims/domain' +import { writeAudit } from './audit' +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 +} + +interface ClientRow { + id: string; code: string; name: string; gstin: string | null; state_code: string + address: string; contacts: string; status: string; notes: string + 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, + } +} + +function assertGstin(gstin: string): void { + const v = validateGstin(gstin) + if (!v.ok) throw new Error(`Invalid GSTIN (${v.reason ?? 'invalid'}): ${gstin}`) +} + +export function listClients(db: DB, q?: string): Client[] { + let rows: ClientRow[] + if (q !== undefined && q !== '') { + const like = `%${q}%` + rows = db.prepare( + `SELECT * FROM client WHERE name LIKE ? OR code LIKE ? OR gstin LIKE ? ORDER BY name`, + ).all(like, like, like) as ClientRow[] + } else { + rows = db.prepare(`SELECT * FROM client ORDER BY name`).all() as ClientRow[] + } + return rows.map(toClient) +} + +export function getClient(db: DB, id: string): Client | null { + const row = db.prepare(`SELECT * FROM client WHERE id=?`).get(id) as ClientRow | undefined + 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 +} + +export function createClient(db: DB, userId: string, input: ClientInput): Client { + if (input.gstin !== undefined) assertGstin(input.gstin) + const id = uuidv7() + const n = (db.prepare(`SELECT COUNT(*) AS n FROM client`).get() as { n: number }).n + const code = input.code ?? `CL${String(n + 1).padStart(4, '0')}` + db.prepare( + `INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status, notes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, code, input.name, input.gstin ?? null, input.stateCode, + input.address ?? '', JSON.stringify(input.contacts ?? []), + input.status ?? 'active', input.notes ?? '', new Date().toISOString(), + ) + const client = getClient(db, id)! + 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 +} + +export function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Client { + const before = 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 (sets.length > 0) { + args.push(id) + db.prepare(`UPDATE client SET ${sets.join(', ')} WHERE id=?`).run(...args) + } + const after = getClient(db, id)! + writeAudit(db, userId, 'update', 'client', id, before, after) + return after +} diff --git a/apps/hq/test/clients.test.ts b/apps/hq/test/clients.test.ts new file mode 100644 index 0000000..a6d0659 --- /dev/null +++ b/apps/hq/test/clients.test.ts @@ -0,0 +1,22 @@ +// apps/hq/test/clients.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createClient, listClients, updateClient } from '../src/repos-clients' + +describe('client registry', () => { + it('creates with auto code, searches, updates with audit', () => { + const db = openDb(':memory:') + const c = createClient(db, 'u1', { name: 'Malabar Stores', stateCode: '32', + contacts: [{ name: 'Ravi', email: 'ravi@malabar.in' }] }) + expect(c.code).toBe('CL0001') + expect(listClients(db, 'malabar')).toHaveLength(1) + const up = updateClient(db, 'u1', c.id, { status: 'active', notes: 'AMC due Oct' }) + expect(up.status).toBe('active') + const audits = db.prepare(`SELECT action FROM audit_log WHERE entity='client'`).all() + expect(audits.length).toBe(2) + }) + it('rejects a bad GSTIN checksum', () => { + const db = openDb(':memory:') + expect(() => createClient(db, 'u1', { name: 'X', stateCode: '32', gstin: '32AAAAA0000A1Z9' })).toThrow() + }) +})