From 3c2e51ae6c2148ce43370618a8c48be4c066926c Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 22:32:50 +0530 Subject: [PATCH] =?UTF-8?q?feat(d22):=20P1=20=E2=80=94=20onboarding=20mile?= =?UTF-8?q?stone=20tracker=20(backend)=20+=20monotonic=20uuidv7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit project_milestone: each client_module (a project) carries a checklist seeded from the dated project.milestone_template setting (advance paid / setup / installed / go-live / balance / AMC — owner-editable). Ticking stamps the date; audited. ensureProjectMilestones is idempotent and back-fills imported projects. Cross-project board (per-milestone pending counts) + drill-down (who's pending go-live). Routes: GET/POST /client-modules/:id/milestones, GET /projects/board, /projects/pending/:key. assignModule seeds the checklist for new projects. Also fixes a latent flaky-ordering bug: uuidv7() now bumps monotonically within a process, so ORDER BY id is genuine creation order (the guarantee pagination + the sync cursor already assume) — sub-millisecond ties no longer fall back to the random tail. Suite 377 green, stable across runs. Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 43 +++++++++ apps/hq/src/db.ts | 9 ++ apps/hq/src/migrations-pg.ts | 11 +++ apps/hq/src/repos-milestones.ts | 154 ++++++++++++++++++++++++++++++++ apps/hq/src/repos-modules.ts | 2 + apps/hq/src/seed.ts | 20 ++++- apps/hq/test/milestones.test.ts | 85 ++++++++++++++++++ packages/domain/src/ids.ts | 7 ++ 8 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 apps/hq/src/repos-milestones.ts create mode 100644 apps/hq/test/milestones.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index e611e37..bcc07a4 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -60,6 +60,10 @@ import { import { pullMonthlyCosts } from './aws-costs' import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports' import { listUsageRateCards, setUsageRateCard } from './repos-rate-card' +import { + addProjectMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, + projectsPendingMilestone, setMilestone, +} from './repos-milestones' import { commitImport, stageCsv, verificationReport } from './import-apex' import { documentHtml, documentHtmlSample } from './templates' import { renderPdf } from './pdf' @@ -629,6 +633,45 @@ export function apiRouter( } }) + // ---------- onboarding milestones (D22) ---------- + r.get('/client-modules/:id/milestones', requireAuth, async (req, res) => { + const id = String(req.params['id'] ?? '') + if ((await getClientModule(db, id)) === null) { + res.status(404).json({ ok: false, error: 'Client module not found' }); return + } + await ensureProjectMilestones(db, id) // lazily seed for projects created before D22 (imports) + res.json({ ok: true, milestones: await listProjectMilestones(db, id) }) + }) + r.post('/client-modules/:id/milestones', requireAuth, async (req, res) => { + const id = String(req.params['id'] ?? '') + if ((await getClientModule(db, id)) === null) { + res.status(404).json({ ok: false, error: 'Client module not found' }); return + } + try { + const body = req.body as { key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown } + if (typeof body.label === 'string') { + // Add a project-specific milestone. + res.json({ ok: true, milestones: await addProjectMilestone(db, staffId(res), id, body.label) }) + return + } + if (typeof body.key !== 'string' || typeof body.done !== 'boolean') { + throw new Error('Provide { key, done } to tick a milestone, or { label } to add one') + } + const milestones = await setMilestone(db, staffId(res), id, body.key, body.done, + typeof body.doneOn === 'string' ? body.doneOn : undefined) + res.json({ ok: true, milestones }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // The cross-project board + drill-down (the "who's pending go-live" view). + r.get('/projects/board', requireAuth, async (_req, res) => { + res.json({ ok: true, board: await milestoneBoard(db) }) + }) + r.get('/projects/pending/:key', requireAuth, async (req, res) => { + res.json({ ok: true, projects: await projectsPendingMilestone(db, String(req.params['key'] ?? '')) }) + }) + // ---------- client branches (D20) ---------- r.get('/clients/:id/branches', requireAuth, async (req, res) => { try { diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index 6a7f140..ad5f3bc 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -136,6 +136,15 @@ CREATE TABLE IF NOT EXISTS client_branch ( id TEXT PRIMARY KEY, client_id TEXT NOT NULL, name TEXT NOT NULL, code TEXT, active INTEGER NOT NULL DEFAULT 1 ); +-- D22 onboarding tracker: each client_module (a "project") gets a checklist of milestones +-- seeded from the dated project.milestone_template setting on assignment. done_on stamps +-- when a milestone is ticked. Reportable across all projects (who is pending go-live). +CREATE TABLE IF NOT EXISTS project_milestone ( + id TEXT PRIMARY KEY, client_module_id TEXT NOT NULL, + key TEXT NOT NULL, label TEXT NOT NULL, + done INTEGER NOT NULL DEFAULT 0, done_on TEXT, sort INTEGER NOT NULL DEFAULT 0, + UNIQUE (client_module_id, key) +); CREATE TABLE IF NOT EXISTS ticket ( id TEXT PRIMARY KEY, client_id TEXT NOT NULL, branch_id TEXT, module_code TEXT, -- 'SMS' | 'RTGS' | ... (free text; module codes by convention) diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index 6e893f5..e799c17 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -224,6 +224,17 @@ CREATE TABLE IF NOT EXISTS usage_rate_band ( id text PRIMARY KEY, module_id text NOT NULL, effective_from text NOT NULL, min_qty bigint NOT NULL, rate_paise bigint NOT NULL ); +`, + }, + { + id: '005-project-milestone', + sql: ` +CREATE TABLE IF NOT EXISTS project_milestone ( + id text PRIMARY KEY, client_module_id text NOT NULL, + key text NOT NULL, label text NOT NULL, + done integer NOT NULL DEFAULT 0, done_on text, sort integer NOT NULL DEFAULT 0, + UNIQUE (client_module_id, key) +); `, }, ] diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts new file mode 100644 index 0000000..f2ed6e7 --- /dev/null +++ b/apps/hq/src/repos-milestones.ts @@ -0,0 +1,154 @@ +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' + +/** + * Onboarding tracker (D22). Each client_module (a "project") carries a checklist of + * milestones — advance paid, setup, install, go-live, balance, AMC — seeded from the + * dated `project.milestone_template` setting. Ticking one stamps the date. Reportable + * across the whole book: "who's pending go-live", "who hasn't paid the advance". + */ + +export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } +interface MilestoneRow { key: string; label: string; done: number; done_on: string | null; sort: number } + +interface TemplateEntry { key: string; label: string } + +const FALLBACK_TEMPLATE: TemplateEntry[] = [ + { key: 'advance_paid', label: 'Advance payment received' }, + { key: 'setup_done', label: 'Setup / configuration done' }, + { key: 'installed', label: 'Installation done' }, + { key: 'go_live', label: 'Go-live' }, + { key: 'balance_paid', label: 'Balance payment received' }, + { key: 'amc_start', label: 'AMC / renewal start' }, +] + +/** The standard checklist (owner-editable via the setting; fallback to the code default). */ +export async function milestoneTemplate(db: DB): Promise { + const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`) + if (row === undefined) return FALLBACK_TEMPLATE + try { + const parsed = JSON.parse(row.value) as TemplateEntry[] + return Array.isArray(parsed) && parsed.length > 0 ? parsed : FALLBACK_TEMPLATE + } catch { return FALLBACK_TEMPLATE } +} + +/** + * Idempotently materialize the template's milestones for a project. New template rows are + * added on later calls (so extending the standard set reaches existing projects); a + * project's own custom milestones and any ticked state are never touched. + */ +export async function ensureProjectMilestones(db: DB, clientModuleId: string): Promise { + const template = await milestoneTemplate(db) + const existing = new Set( + (await db.all<{ key: string }>(`SELECT key FROM project_milestone WHERE client_module_id=?`, clientModuleId)) + .map((r) => r.key), + ) + await db.transaction(async () => { + let sort = 0 + for (const t of template) { + if (!existing.has(t.key)) { + await db.run( + `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) + VALUES (?, ?, ?, ?, 0, NULL, ?)`, + uuidv7(), clientModuleId, t.key, t.label, sort, + ) + } + sort++ + } + }) +} + +export async function listProjectMilestones(db: DB, clientModuleId: string): Promise { + const rows = await db.all( + `SELECT key, label, done, done_on, sort FROM project_milestone + WHERE client_module_id=? ORDER BY sort, label`, + clientModuleId, + ) + return rows.map((r) => ({ key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort })) +} + +/** Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears it. Audited. */ +export async function setMilestone( + db: DB, userId: string, clientModuleId: string, key: string, done: boolean, doneOn?: string, +): Promise { + return db.transaction(async () => { + const before = await db.get( + `SELECT key, label, done, done_on, sort FROM project_milestone WHERE client_module_id=? AND key=?`, + clientModuleId, key, + ) + if (before === undefined) throw new Error('Milestone not found on this project') + if (done && doneOn !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(doneOn)) { + throw new Error('doneOn must be YYYY-MM-DD') + } + const stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null + await db.run( + `UPDATE project_milestone SET done=?, done_on=? WHERE client_module_id=? AND key=?`, + done ? 1 : 0, stamp, clientModuleId, key, + ) + await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, { key, done, doneOn: stamp }) + return listProjectMilestones(db, clientModuleId) + }) +} + +/** Add a project-specific milestone (beyond the standard template). Audited. */ +export async function addProjectMilestone( + db: DB, userId: string, clientModuleId: string, label: string, +): Promise { + return db.transaction(async () => { + if (label.trim() === '') throw new Error('Milestone label is required') + const key = `custom_${uuidv7().slice(0, 8)}` + const maxSort = (await db.get<{ m: number }>( + `SELECT COALESCE(MAX(sort), -1) AS m FROM project_milestone WHERE client_module_id=?`, clientModuleId, + ))!.m + await db.run( + `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) + VALUES (?, ?, ?, ?, 0, NULL, ?)`, + uuidv7(), clientModuleId, key, label.trim(), maxSort + 1, + ) + await writeAudit(db, userId, 'add_milestone', 'client_module', clientModuleId, undefined, { key, label: label.trim() }) + return listProjectMilestones(db, clientModuleId) + }) +} + +// ---------- cross-project reporting (the board) ---------- + +export interface MilestoneBoardRow { key: string; label: string; total: number; done: number; pending: number } + +/** + * Per-milestone completion across ALL active projects — the onboarding board. Ordered by + * the standard template; ad-hoc custom milestones are aggregated too, after the standard set. + */ +export async function milestoneBoard(db: DB): Promise { + const rows = await db.all<{ key: string; label: string; total: number; done: number; minsort: number }>( + `SELECT pm.key, MIN(pm.label) AS label, + COUNT(*) AS total, + COALESCE(SUM(pm.done), 0) AS done, + MIN(pm.sort) AS minsort + FROM project_milestone pm + JOIN client_module cm ON cm.id = pm.client_module_id + WHERE cm.active = 1 + GROUP BY pm.key + ORDER BY minsort, label`, + ) + return rows.map((r) => ({ key: r.key, label: r.label, total: r.total, done: r.done, pending: r.total - r.done })) +} + +export interface PendingProject { + clientModuleId: string; clientId: string; clientName: string; moduleCode: string; moduleName: string +} + +/** Active projects where the given milestone is NOT yet done — the drill-down. */ +export async function projectsPendingMilestone(db: DB, key: string): Promise { + return db.all( + `SELECT pm.client_module_id AS "clientModuleId", cm.client_id AS "clientId", + c.name AS "clientName", m.code AS "moduleCode", m.name AS "moduleName" + FROM project_milestone pm + JOIN client_module cm ON cm.id = pm.client_module_id + JOIN client c ON c.id = cm.client_id + JOIN module m ON m.id = cm.module_id + WHERE cm.active = 1 AND pm.key = ? AND pm.done = 0 + ORDER BY c.name`, + key, + ) +} diff --git a/apps/hq/src/repos-modules.ts b/apps/hq/src/repos-modules.ts index f97f7ad..f4894a6 100644 --- a/apps/hq/src/repos-modules.ts +++ b/apps/hq/src/repos-modules.ts @@ -1,6 +1,7 @@ import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import { decrypt, encrypt } from './crypto' +import { ensureProjectMilestones } from './repos-milestones' import type { DB } from './db' /** Module catalog + dated price book — plain functions over the handle (D12 portable-repo pattern). */ @@ -381,6 +382,7 @@ export async function assignModule(db: DB, userId: string, input: AssignModuleIn VALUES (?, ?, ?, ?, ?, ?)`, id, input.clientId, input.moduleId, status, input.kind, input.edition ?? 'standard', ) + await ensureProjectMilestones(db, id) // D22: seed the onboarding checklist for the new project const cm = (await getClientModule(db, id))! await writeAudit(db, userId, 'create', 'client_module', id, undefined, cm) return cm diff --git a/apps/hq/src/seed.ts b/apps/hq/src/seed.ts index 99e51f6..ec90c18 100644 --- a/apps/hq/src/seed.ts +++ b/apps/hq/src/seed.ts @@ -8,7 +8,11 @@ import { SCHEDULE_DEFAULTS, type ScheduleRuleKind } from './repos-reminders' /** First-boot seed: owner login, company settings placeholders, GST18 tax class. */ -const OWNER_EMAIL = 'admin@tecnostac.com' +// First-boot bootstrap owner only (empty-DB chicken-and-egg: no employees yet → nobody +// to log in → can't open the Employees page). Set HQ_OWNER_EMAIL at deploy so the first +// employee IS the real owner; all other staff are then created in the owner-only +// Employees page. Neutral fallback keeps any company address out of the code. +const OWNER_EMAIL = process.env['HQ_OWNER_EMAIL']?.trim() || 'owner@sims.local' const SETTING_DEFAULTS: Record = { 'company.name': 'Tecnostac', @@ -82,6 +86,20 @@ export async function seedIfEmpty(db: DB): Promise { } if (seededBilling > 0) await writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS) + // Onboarding milestone template (D22): the standard checklist every new project + // (client_module) starts with. Config over code — owner-editable; a project can also add + // its own milestones. Order here is the display order. + const MILESTONE_TEMPLATE = JSON.stringify([ + { key: 'advance_paid', label: 'Advance payment received' }, + { key: 'setup_done', label: 'Setup / configuration done' }, + { key: 'installed', label: 'Installation done' }, + { key: 'go_live', label: 'Go-live' }, + { key: 'balance_paid', label: 'Balance payment received' }, + { key: 'amc_start', label: 'AMC / renewal start' }, + ]) + const seededMilestone = (await db.run(INSERT_SETTING_SQL, 'project.milestone_template', MILESTONE_TEMPLATE)).changes + if (seededMilestone > 0) await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template', undefined, { set: true }) + // Dated reminder schedules (rule 3 / D-REMIND): one open-ended row per rule kind, // matching the code-constant fallback. A cadence change is a NEW dated row, not an edit. const scheduleKinds: ScheduleRuleKind[] = ['quote_followup', 'invoice_overdue'] diff --git a/apps/hq/test/milestones.test.ts b/apps/hq/test/milestones.test.ts new file mode 100644 index 0000000..cf1a994 --- /dev/null +++ b/apps/hq/test/milestones.test.ts @@ -0,0 +1,85 @@ +// apps/hq/test/milestones.test.ts — D22: onboarding milestone tracker. +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 } from '../src/repos-modules' +import { + listProjectMilestones, setMilestone, addProjectMilestone, ensureProjectMilestones, + milestoneBoard, projectsPendingMilestone, +} from '../src/repos-milestones' + +async function world() { + const db = openDb(':memory:') + await seedIfEmpty(db) + const c1 = await createClient(db, 'u1', { name: 'Alpha SCB', stateCode: '32' }) + const c2 = await createClient(db, 'u1', { name: 'Beta SCB', stateCode: '32' }) + const m = await createModule(db, 'u1', { code: 'CLOUDBACKUP', name: 'Cloud Backup' }) + const p1 = await assignModule(db, 'u1', { clientId: c1.id, moduleId: m.id, kind: 'yearly' }) + const p2 = await assignModule(db, 'u1', { clientId: c2.id, moduleId: m.id, kind: 'yearly' }) + return { db, c1, c2, m, p1, p2 } +} + +describe('project milestones (D22)', () => { + it('seeds the standard checklist on assignment, in template order', async () => { + const { db, p1 } = await world() + const ms = await listProjectMilestones(db, p1.id) + expect(ms.map((m) => m.key)).toEqual(['advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start']) + expect(ms.every((m) => !m.done && m.doneOn === null)).toBe(true) + }) + + it('ticks a milestone (stamps the date), unticks (clears it), audited', async () => { + const { db, p1 } = await world() + const after = await setMilestone(db, 'u1', p1.id, 'advance_paid', true, '2026-07-12') + expect(after.find((m) => m.key === 'advance_paid')).toMatchObject({ done: true, doneOn: '2026-07-12' }) + const todayTick = await setMilestone(db, 'u1', p1.id, 'go_live', true) // defaults to today + expect(todayTick.find((m) => m.key === 'go_live')!.doneOn).toMatch(/^\d{4}-\d{2}-\d{2}$/) + const cleared = await setMilestone(db, 'u1', p1.id, 'advance_paid', false) + expect(cleared.find((m) => m.key === 'advance_paid')).toMatchObject({ done: false, doneOn: null }) + const audits = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log WHERE action='set_milestone'`))!.n + expect(audits).toBe(3) + await expect(setMilestone(db, 'u1', p1.id, 'nope', true)).rejects.toThrow(/not found/) + await expect(setMilestone(db, 'u1', p1.id, 'go_live', true, 'bad-date')).rejects.toThrow(/YYYY-MM-DD/) + }) + + it('adds a project-specific milestone after the standard set', async () => { + const { db, p1 } = await world() + const ms = await addProjectMilestone(db, 'u1', p1.id, 'AWS region confirmed') + expect(ms).toHaveLength(7) + expect(ms[ms.length - 1]!.label).toBe('AWS region confirmed') + await expect(addProjectMilestone(db, 'u1', p1.id, ' ')).rejects.toThrow(/label/) + }) + + it('ensureProjectMilestones is idempotent and back-fills imported projects', async () => { + const { db, c1, m } = await world() + // Simulate an imported project (direct insert, bypassing assignModule). + await db.run( + `INSERT INTO client_module (id, client_id, module_id, status, kind, edition) VALUES (?, ?, ?, 'live', 'yearly', 'standard')`, + 'imported-cm', c1.id, m.id, + ) + expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(0) + await ensureProjectMilestones(db, 'imported-cm') + expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(6) + await ensureProjectMilestones(db, 'imported-cm') // second call adds nothing + expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(6) + }) + + it('boards the whole book and drills into who is pending a milestone', async () => { + const { db, c1, p1, p2 } = await world() + await setMilestone(db, 'u1', p1.id, 'advance_paid', true) + await setMilestone(db, 'u1', p1.id, 'go_live', true) + await setMilestone(db, 'u1', p2.id, 'advance_paid', true) + const board = await milestoneBoard(db) + const advance = board.find((b) => b.key === 'advance_paid')! + expect(advance).toMatchObject({ total: 2, done: 2, pending: 0 }) + const goLive = board.find((b) => b.key === 'go_live')! + expect(goLive).toMatchObject({ total: 2, done: 1, pending: 1 }) + const pendingGoLive = await projectsPendingMilestone(db, 'go_live') + expect(pendingGoLive).toHaveLength(1) + expect(pendingGoLive[0]).toMatchObject({ clientId: p2.clientId, moduleCode: 'CLOUDBACKUP' }) + // Deactivating a project drops it from the board. + await db.run(`UPDATE client_module SET active=0 WHERE id=?`, p2.id) + expect((await milestoneBoard(db)).find((b) => b.key === 'go_live')).toMatchObject({ total: 1, done: 1, pending: 0 }) + void c1 + }) +}) diff --git a/packages/domain/src/ids.ts b/packages/domain/src/ids.ts index b033e81..c9aeca8 100644 --- a/packages/domain/src/ids.ts +++ b/packages/domain/src/ids.ts @@ -4,7 +4,14 @@ * relies on for cheap cursor pagination. Client-generated, never server-assigned. * Web Crypto keeps this package loadable in both Node and the POS renderer. */ +let lastMs = 0 export function uuidv7(now: number = Date.now()): string { + // Monotonic within a process: two ids minted in the same millisecond (or an earlier + // one, e.g. a passed-in timestamp) still strictly increase — so `ORDER BY id` is genuine + // creation order, the property pagination and the sync cursor both rely on. Without this, + // sub-millisecond ties fall back to the random tail and the order is undefined. + lastMs = now > lastMs ? now : lastMs + 1 + now = lastMs const b = new Uint8Array(16) globalThis.crypto.getRandomValues(b) b[0] = (now / 2 ** 40) & 0xff