feat(hq): client registry with GSTIN validation (HQ-1 task 4)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent ac7aa891db
commit 9e4d53fb65

@ -1,6 +1,12 @@
import { Router } from 'express' import { Router, type Response } from 'express'
import { login } from './auth' import { login, requireAuth } from './auth'
import type { DB } from './db' 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 { export function apiRouter(db: DB): Router {
const r = 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 } if (!out) { res.status(401).json({ ok: false, error: 'Invalid email or password' }); return }
res.json({ ok: true, ...out }) 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 return r
} }

@ -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
}

@ -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()
})
})
Loading…
Cancel
Save