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
parent
0e85163b68
commit
3c2e51ae6c
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue