From 78771dad8d5f574c2c24a496bdf50f33aa430652 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 01:00:28 +0530 Subject: [PATCH] feat(hq): client-module lifecycle tracking (HQ-1 task 6) Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 40 ++++++++++- apps/hq/src/repos-modules.ts | 104 ++++++++++++++++++++++++++++ apps/hq/test/client-modules.test.ts | 24 +++++++ 3 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 apps/hq/test/client-modules.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 0204439..0df04f4 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -6,8 +6,9 @@ import { type ClientInput, type ClientPatch, } from './repos-clients' import { - createModule, getModule, listModules, listPrices, setPrice, - type ModuleInput, type PriceInput, + assignModule, createModule, getClientModule, getModule, listClientModules, + listModules, listPrices, setPrice, updateClientModule, + type AssignModuleInput, type ClientModulePatch, type ModuleInput, type PriceInput, } from './repos-modules' const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id @@ -87,5 +88,40 @@ export function apiRouter(db: DB): Router { } }) + // ---------- client modules ---------- + r.get('/clients/:id/modules', 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 + } + res.json({ ok: true, clientModules: listClientModules(db, id) }) + }) + r.post('/clients/:id/modules', 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 cm = assignModule(db, staffId(res), { + ...(req.body as Omit), clientId: id, + }) + res.json({ ok: true, clientModule: cm }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.patch('/client-modules/:id', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClientModule(db, id) === null) { + res.status(404).json({ ok: false, error: 'Client module not found' }); return + } + try { + const cm = updateClientModule(db, staffId(res), id, req.body as ClientModulePatch) + res.json({ ok: true, clientModule: cm }) + } 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-modules.ts b/apps/hq/src/repos-modules.ts index dbf1455..1ddc776 100644 --- a/apps/hq/src/repos-modules.ts +++ b/apps/hq/src/repos-modules.ts @@ -115,3 +115,107 @@ export function priceOn(db: DB, moduleId: string, kind: Kind, edition: string, o ).get(moduleId, kind, edition, onDate) as { price_paise: number } | undefined return row === undefined ? null : row.price_paise } + +// ---------- client-module tracking (lifecycle + multi-subscription rule) ---------- + +export type ClientModuleStatus = + | 'quoted' | 'ordered' | 'installing' | 'installed' + | 'trained' | 'live' | 'expired' | 'cancelled' + +const ALL_STATUSES: ClientModuleStatus[] = [ + 'quoted', 'ordered', 'installing', 'installed', 'trained', 'live', 'expired', 'cancelled', +] + +export interface ClientModule { + id: string; clientId: string; moduleId: string; status: ClientModuleStatus + kind: Kind; edition: string + installedOn: string | null; completedOn: string | null; trainedOn: string | null + nextRenewal: string | null; active: boolean +} + +interface ClientModuleRow { + id: string; client_id: string; module_id: string; status: string + kind: string; edition: string + installed_on: string | null; completed_on: string | null; trained_on: string | null + next_renewal: string | null; active: number +} + +function toClientModule(r: ClientModuleRow): ClientModule { + return { + id: r.id, clientId: r.client_id, moduleId: r.module_id, + status: r.status as ClientModuleStatus, kind: r.kind as Kind, edition: r.edition, + installedOn: r.installed_on, completedOn: r.completed_on, trainedOn: r.trained_on, + nextRenewal: r.next_renewal, active: r.active === 1, + } +} + +export function getClientModule(db: DB, id: string): ClientModule | null { + const row = db.prepare(`SELECT * FROM client_module WHERE id=?`).get(id) as ClientModuleRow | undefined + return row === undefined ? null : toClientModule(row) +} + +export function listClientModules(db: DB, clientId: string): ClientModule[] { + const rows = db.prepare( + `SELECT * FROM client_module WHERE client_id=? ORDER BY id`, + ).all(clientId) as ClientModuleRow[] + return rows.map(toClientModule) +} + +export interface AssignModuleInput { + clientId: string; moduleId: string; kind: Kind + edition?: string; status?: ClientModuleStatus +} + +export function assignModule(db: DB, userId: string, input: AssignModuleInput): ClientModule { + const mod = getModule(db, input.moduleId) + if (mod === null) throw new Error('Module not found') + if (!mod.allowedKinds.includes(input.kind)) { + throw new Error(`Module ${mod.code} does not allow kind '${input.kind}' (allowed: ${mod.allowedKinds.join(', ')})`) + } + const status = input.status ?? 'quoted' + if (!ALL_STATUSES.includes(status)) throw new Error(`Unknown status: ${status}`) + if (!mod.multiSubscription) { + const existing = db.prepare( + `SELECT COUNT(*) AS n FROM client_module WHERE client_id=? AND module_id=? AND active=1`, + ).get(input.clientId, input.moduleId) as { n: number } + if (existing.n > 0) { + throw new Error(`Client already has an active subscription for module ${mod.code}`) + } + } + const id = uuidv7() + db.prepare( + `INSERT INTO client_module (id, client_id, module_id, status, kind, edition) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(id, input.clientId, input.moduleId, status, input.kind, input.edition ?? 'standard') + const cm = getClientModule(db, id)! + writeAudit(db, userId, 'create', 'client_module', id, undefined, cm) + return cm +} + +export interface ClientModulePatch { + status?: ClientModuleStatus; installedOn?: string | null; completedOn?: string | null + trainedOn?: string | null; nextRenewal?: string | null; active?: boolean +} + +export function updateClientModule(db: DB, userId: string, id: string, patch: ClientModulePatch): ClientModule { + const before = getClientModule(db, id) + if (before === null) throw new Error('Client module not found') + if (patch.status !== undefined && !ALL_STATUSES.includes(patch.status)) { + throw new Error(`Unknown status: ${patch.status}`) + } + const sets: string[] = [] + const args: unknown[] = [] + if (patch.status !== undefined) { sets.push('status=?'); args.push(patch.status) } + if (patch.installedOn !== undefined) { sets.push('installed_on=?'); args.push(patch.installedOn) } + if (patch.completedOn !== undefined) { sets.push('completed_on=?'); args.push(patch.completedOn) } + if (patch.trainedOn !== undefined) { sets.push('trained_on=?'); args.push(patch.trainedOn) } + if (patch.nextRenewal !== undefined) { sets.push('next_renewal=?'); args.push(patch.nextRenewal) } + if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) } + if (sets.length > 0) { + args.push(id) + db.prepare(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`).run(...args) + } + const after = getClientModule(db, id)! + writeAudit(db, userId, 'update', 'client_module', id, before, after) + return after +} diff --git a/apps/hq/test/client-modules.test.ts b/apps/hq/test/client-modules.test.ts new file mode 100644 index 0000000..c0d875e --- /dev/null +++ b/apps/hq/test/client-modules.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createClient } from '../src/repos-clients' +import { createModule, assignModule, updateClientModule } from '../src/repos-modules' + +describe('client modules', () => { + it('enforces allowed kinds and single-subscription', () => { + const db = openDb(':memory:') + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['one_time','yearly'] }) + expect(() => assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })).toThrow(/allow/) + const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + expect(() => assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })).toThrow(/already/) + const up = updateClientModule(db, 'u1', cm.id, { status: 'installed', installedOn: '2026-07-01' }) + expect(up.status).toBe('installed') + }) + it('allows concurrent rows when multi_subscription = 1', () => { + const db = openDb(':memory:') + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', multiSubscription: true }) + assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'usage' }) + expect(() => assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'usage' })).not.toThrow() + }) +})