feat(d22): P1 — onboarding milestone tracker (backend) + monotonic uuidv7

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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 0e85163b68
commit 3c2e51ae6c

@ -60,6 +60,10 @@ import {
import { pullMonthlyCosts } from './aws-costs' import { pullMonthlyCosts } from './aws-costs'
import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports' import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports'
import { listUsageRateCards, setUsageRateCard } from './repos-rate-card' 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 { commitImport, stageCsv, verificationReport } from './import-apex'
import { documentHtml, documentHtmlSample } from './templates' import { documentHtml, documentHtmlSample } from './templates'
import { renderPdf } from './pdf' 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) ---------- // ---------- client branches (D20) ----------
r.get('/clients/:id/branches', requireAuth, async (req, res) => { r.get('/clients/:id/branches', requireAuth, async (req, res) => {
try { try {

@ -136,6 +136,15 @@ CREATE TABLE IF NOT EXISTS client_branch (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, name TEXT NOT NULL, id TEXT PRIMARY KEY, client_id TEXT NOT NULL, name TEXT NOT NULL,
code TEXT, active INTEGER NOT NULL DEFAULT 1 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 ( CREATE TABLE IF NOT EXISTS ticket (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, branch_id TEXT, id TEXT PRIMARY KEY, client_id TEXT NOT NULL, branch_id TEXT,
module_code TEXT, -- 'SMS' | 'RTGS' | ... (free text; module codes by convention) module_code TEXT, -- 'SMS' | 'RTGS' | ... (free text; module codes by convention)

@ -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, id text PRIMARY KEY, module_id text NOT NULL, effective_from text NOT NULL,
min_qty bigint NOT NULL, rate_paise bigint 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)
);
`, `,
}, },
] ]

@ -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<TemplateEntry[]> {
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<void> {
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<Milestone[]> {
const rows = await db.all<MilestoneRow>(
`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<Milestone[]> {
return db.transaction(async () => {
const before = await db.get<MilestoneRow>(
`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<Milestone[]> {
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<MilestoneBoardRow[]> {
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<PendingProject[]> {
return db.all<PendingProject>(
`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,
)
}

@ -1,6 +1,7 @@
import { uuidv7 } from '@sims/domain' import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit' import { writeAudit } from './audit'
import { decrypt, encrypt } from './crypto' import { decrypt, encrypt } from './crypto'
import { ensureProjectMilestones } from './repos-milestones'
import type { DB } from './db' import type { DB } from './db'
/** Module catalog + dated price book — plain functions over the handle (D12 portable-repo pattern). */ /** 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 (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
id, input.clientId, input.moduleId, status, input.kind, input.edition ?? 'standard', 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))! const cm = (await getClientModule(db, id))!
await writeAudit(db, userId, 'create', 'client_module', id, undefined, cm) await writeAudit(db, userId, 'create', 'client_module', id, undefined, cm)
return cm return cm

@ -8,7 +8,11 @@ import { SCHEDULE_DEFAULTS, type ScheduleRuleKind } from './repos-reminders'
/** First-boot seed: owner login, company settings placeholders, GST18 tax class. */ /** 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<string, string> = { const SETTING_DEFAULTS: Record<string, string> = {
'company.name': 'Tecnostac', 'company.name': 'Tecnostac',
@ -82,6 +86,20 @@ export async function seedIfEmpty(db: DB): Promise<void> {
} }
if (seededBilling > 0) await writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS) 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, // 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. // matching the code-constant fallback. A cadence change is a NEW dated row, not an edit.
const scheduleKinds: ScheduleRuleKind[] = ['quote_followup', 'invoice_overdue'] const scheduleKinds: ScheduleRuleKind[] = ['quote_followup', 'invoice_overdue']

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

@ -4,7 +4,14 @@
* relies on for cheap cursor pagination. Client-generated, never server-assigned. * relies on for cheap cursor pagination. Client-generated, never server-assigned.
* Web Crypto keeps this package loadable in both Node and the POS renderer. * Web Crypto keeps this package loadable in both Node and the POS renderer.
*/ */
let lastMs = 0
export function uuidv7(now: number = Date.now()): string { 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) const b = new Uint8Array(16)
globalThis.crypto.getRandomValues(b) globalThis.crypto.getRandomValues(b)
b[0] = (now / 2 ** 40) & 0xff b[0] = (now / 2 ** 40) & 0xff

Loading…
Cancel
Save