From b2c49832d5b062c9d38b6bedd6b2b32e474ce7b3 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 22:59:17 +0530 Subject: [PATCH] feat(onboarding): status line auto-drives client_module.status (never downgrades) Ticking the installation/training/go_live onboarding milestone steps now advances the coarse client_module.status (installed/trained/live), audited in the same transaction. A STATUS_RANK guard blocks downgrades from unticking or ticking an earlier step after a later one already landed. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq/src/repos-milestones.ts | 20 ++++++++++++ apps/hq/test/milestone-status.test.ts | 45 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 apps/hq/test/milestone-status.test.ts diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index b2f2433..0703a51 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -14,6 +14,25 @@ interface MilestoneRow { key: string; label: string; done: number; done_on: stri export interface TemplateEntry { key: string; label: string } +/** Ticking a step can advance the coarse client_module.status — never downgrade. Ranks match + * the status CHECK order: quoted = { + quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7, +} +const STEP_STATUS: Record = { + installation: 'installed', training: 'trained', go_live: 'live', +} + +async function advanceStatusForStep(db: DB, userId: string, clientModuleId: string, key: string): Promise { + const target = STEP_STATUS[key] + if (target === undefined) return + const cm = await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, clientModuleId) + if (cm === undefined) return + if ((STATUS_RANK[cm.status] ?? 0) >= (STATUS_RANK[target] ?? 0)) return // never downgrade + await db.run(`UPDATE client_module SET status=? WHERE id=?`, target, clientModuleId) + await writeAudit(db, userId, 'update', 'client_module', clientModuleId, { status: cm.status }, { status: target }) +} + export const FALLBACK_TEMPLATE: TemplateEntry[] = [ { key: 'advance_paid', label: 'Advance payment received' }, { key: 'setup_done', label: 'Setup / configuration done' }, @@ -99,6 +118,7 @@ export async function setMilestone( done ? 1 : 0, stamp, clientModuleId, key, ) await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, { key, done, doneOn: stamp }) + if (done) await advanceStatusForStep(db, userId, clientModuleId, key) return listProjectMilestones(db, clientModuleId) }) } diff --git a/apps/hq/test/milestone-status.test.ts b/apps/hq/test/milestone-status.test.ts new file mode 100644 index 0000000..924137b --- /dev/null +++ b/apps/hq/test/milestone-status.test.ts @@ -0,0 +1,45 @@ +// apps/hq/test/milestone-status.test.ts — milestone→status auto-drive (never downgrade). +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, assignModule, getClientModule } from '../src/repos-modules' +import { setMilestone } from '../src/repos-milestones' + +async function world() { + const db = openDb(':memory:') + await seedIfEmpty(db) + const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) + const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + return { db, c, m, cmId: cm.id } +} + +describe('milestone → status auto-drive (D22 status line)', () => { + it('ticking installation advances status to installed', async () => { + const { db, cmId } = await world() + await setMilestone(db, 'u1', cmId, 'installation', true) + const cm = await getClientModule(db, cmId) + expect(cm!.status).toBe('installed') + }) + + it('ticking go_live advances status to live', async () => { + const { db, cmId } = await world() + await setMilestone(db, 'u1', cmId, 'go_live', true) + expect((await getClientModule(db, cmId))!.status).toBe('live') + }) + + it('unticking never downgrades status', async () => { + const { db, cmId } = await world() + await setMilestone(db, 'u1', cmId, 'go_live', true) + await setMilestone(db, 'u1', cmId, 'go_live', false) + expect((await getClientModule(db, cmId))!.status).toBe('live') + }) + + it('a lower step never downgrades a higher status', async () => { + const { db, cmId } = await world() + await setMilestone(db, 'u1', cmId, 'go_live', true) // live + await setMilestone(db, 'u1', cmId, 'installation', true) // still live + expect((await getClientModule(db, cmId))!.status).toBe('live') + }) +})