feat(onboarding): resolve milestone template per module (config over code)

Widens milestoneTemplate(db, moduleCode?) to resolve project.milestone_template:<CODE>
before the global project.milestone_template setting, falling back to FALLBACK_TEMPLATE.
ensureProjectMilestones now looks up the client_module's module code and resolves the
template through it, so each module can carry its own onboarding checklist as a dated
setting row instead of one hardcoded global list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent 6bc92bace3
commit 1e77792beb

@ -12,9 +12,9 @@ import type { DB } from './db'
export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } 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 MilestoneRow { key: string; label: string; done: number; done_on: string | null; sort: number }
interface TemplateEntry { key: string; label: string } export interface TemplateEntry { key: string; label: string }
const FALLBACK_TEMPLATE: TemplateEntry[] = [ export const FALLBACK_TEMPLATE: TemplateEntry[] = [
{ key: 'advance_paid', label: 'Advance payment received' }, { key: 'advance_paid', label: 'Advance payment received' },
{ key: 'setup_done', label: 'Setup / configuration done' }, { key: 'setup_done', label: 'Setup / configuration done' },
{ key: 'installed', label: 'Installation done' }, { key: 'installed', label: 'Installation done' },
@ -23,14 +23,23 @@ const FALLBACK_TEMPLATE: TemplateEntry[] = [
{ key: 'amc_start', label: 'AMC / renewal start' }, { key: 'amc_start', label: 'AMC / renewal start' },
] ]
/** The standard checklist (owner-editable via the setting; fallback to the code default). */ /** The checklist for a module (owner-editable per module via setting; global default; code fallback). */
export async function milestoneTemplate(db: DB): Promise<TemplateEntry[]> { export async function milestoneTemplate(db: DB, moduleCode?: string): Promise<TemplateEntry[]> {
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`) const parse = (value: string): TemplateEntry[] | undefined => {
if (row === undefined) return FALLBACK_TEMPLATE
try { try {
const parsed = JSON.parse(row.value) as TemplateEntry[] const p = JSON.parse(value) as TemplateEntry[]
return Array.isArray(parsed) && parsed.length > 0 ? parsed : FALLBACK_TEMPLATE return Array.isArray(p) && p.length > 0 ? p : undefined
} catch { return FALLBACK_TEMPLATE } } catch { return undefined }
}
if (moduleCode !== undefined && moduleCode !== '') {
const perModule = await db.get<{ value: string }>(
`SELECT value FROM setting WHERE key=?`, `project.milestone_template:${moduleCode}`)
const parsed = perModule !== undefined ? parse(perModule.value) : undefined
if (parsed !== undefined) return parsed
}
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`)
const parsedGlobal = row !== undefined ? parse(row.value) : undefined
return parsedGlobal ?? FALLBACK_TEMPLATE
} }
/** /**
@ -39,7 +48,10 @@ export async function milestoneTemplate(db: DB): Promise<TemplateEntry[]> {
* project's own custom milestones and any ticked state are never touched. * project's own custom milestones and any ticked state are never touched.
*/ */
export async function ensureProjectMilestones(db: DB, clientModuleId: string): Promise<void> { export async function ensureProjectMilestones(db: DB, clientModuleId: string): Promise<void> {
const template = await milestoneTemplate(db) const cm = await db.get<{ module_code: string }>(
`SELECT m.code AS module_code FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE cm.id=?`,
clientModuleId)
const template = await milestoneTemplate(db, cm?.module_code)
const existing = new Set( const existing = new Set(
(await db.all<{ key: string }>(`SELECT key FROM project_milestone WHERE client_module_id=?`, clientModuleId)) (await db.all<{ key: string }>(`SELECT key FROM project_milestone WHERE client_module_id=?`, clientModuleId))
.map((r) => r.key), .map((r) => r.key),

@ -0,0 +1,30 @@
// apps/hq/test/milestones-templates.test.ts — per-module onboarding template resolution.
import { describe, it, expect, beforeEach } from 'vitest'
import { openDb, type DB } from '../src/db'
import { milestoneTemplate } from '../src/repos-milestones'
describe('per-module milestone templates', () => {
let db: DB
beforeEach(() => { db = openDb(':memory:') })
it('falls back to the global template when no module-specific one exists', async () => {
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
JSON.stringify([{ key: 'a', label: 'A' }]))
expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'a', label: 'A' }])
})
it('prefers the module-specific template over the global one', async () => {
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
JSON.stringify([{ key: 'a', label: 'A' }]))
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`,
JSON.stringify([{ key: 'doc', label: 'Document collection' }]))
expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'doc', label: 'Document collection' }])
// A different module still gets the global one.
expect(await milestoneTemplate(db, 'RTGS')).toEqual([{ key: 'a', label: 'A' }])
})
it('falls back to the code default when nothing is seeded', async () => {
const t = await milestoneTemplate(db, 'SMS')
expect(t.map((e) => e.key)).toContain('go_live')
})
})
Loading…
Cancel
Save