feat(hq): client-module lifecycle tracking (HQ-1 task 6)

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

@ -6,8 +6,9 @@ import {
type ClientInput, type ClientPatch, type ClientInput, type ClientPatch,
} from './repos-clients' } from './repos-clients'
import { import {
createModule, getModule, listModules, listPrices, setPrice, assignModule, createModule, getClientModule, getModule, listClientModules,
type ModuleInput, type PriceInput, listModules, listPrices, setPrice, updateClientModule,
type AssignModuleInput, type ClientModulePatch, type ModuleInput, type PriceInput,
} from './repos-modules' } from './repos-modules'
const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id 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<AssignModuleInput, 'clientId'>), 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 return r
} }

@ -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 ).get(moduleId, kind, edition, onDate) as { price_paise: number } | undefined
return row === undefined ? null : row.price_paise 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
}

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