feat(onboarding): composed milestone templates (shared front + per-module tail)

Supersedes the flat per-module onboarding templates: milestoneTemplate(db, code)
now returns [...front, ...tail]. front is a shared setting
(project.milestone_template.front, FRONT_FALLBACK code default: enquiry ->
visit/meeting -> quotation sent -> quotation approved) prepended to every
module's checklist. tail resolves per-module setting -> global setting ->
TAIL_FALLBACK (the old generic list), unchanged in precedence.

- STEP_STATUS gains quotation_approved -> ordered (never-downgrade guard kept).
- seed.ts seeds the front once plus SMS/RTGS/MOBILEAPP tails (advance/balance
  payment steps collapsed into a single payment_received tail step); no longer
  seeds a bare project.milestone_template default.
- migrate-onboarding-templates.ts KEY_MAP updated so advance_paid, balance_paid,
  advance_payment and balance_payment all carry forward to payment_received;
  its idempotency check holds against the longer composed list.
- Updated seed-templates, milestones-templates, milestone-status,
  migrate-onboarding-templates, milestones and milestone-payment tests to the
  composed content/mappings (TDD: reverted the implementation, confirmed the
  updated tests red, restored the implementation, confirmed green).

Backend + tests only; the live-DB migration run is a separate step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Thomas Joise 2 days ago
parent e8f00503a4
commit f1a7eac9dd

@ -10,7 +10,8 @@ import type { DB } from './db'
/** old generic key -> new template key (only applied if the new template has that key). */
const KEY_MAP: Record<string, string> = {
installed: 'installation', go_live: 'go_live',
advance_paid: 'advance_payment', balance_paid: 'balance_payment',
advance_paid: 'payment_received', balance_paid: 'payment_received',
advance_payment: 'payment_received', balance_payment: 'payment_received',
setup_done: 'setup_configuration',
}

@ -24,7 +24,7 @@ const STATUS_RANK: Record<string, number> = {
quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7,
}
const STEP_STATUS: Record<string, string> = {
installation: 'installed', training: 'trained', go_live: 'live',
quotation_approved: 'ordered', installation: 'installed', training: 'trained', go_live: 'live',
}
async function advanceStatusForStep(db: DB, userId: string, clientModuleId: string, key: string): Promise<void> {
@ -37,7 +37,16 @@ async function advanceStatusForStep(db: DB, userId: string, clientModuleId: stri
await writeAudit(db, userId, 'update', 'client_module', clientModuleId, { status: cm.status }, { status: target })
}
export const FALLBACK_TEMPLATE: TemplateEntry[] = [
/** The shared lead-in every module's checklist starts with (code default for the "front"). */
export const FRONT_FALLBACK: TemplateEntry[] = [
{ key: 'enquiry', label: 'Enquiry received' },
{ key: 'visit_meeting', label: 'Visit / meeting scheduled' },
{ key: 'quotation_sent', label: 'Quotation sent' },
{ key: 'quotation_approved', label: 'Quotation approved' },
]
/** Code default for the module-specific "tail" when nothing is configured. */
export const TAIL_FALLBACK: TemplateEntry[] = [
{ key: 'advance_paid', label: 'Advance payment received' },
{ key: 'setup_done', label: 'Setup / configuration done' },
{ key: 'installed', label: 'Installation done' },
@ -46,23 +55,36 @@ export const FALLBACK_TEMPLATE: TemplateEntry[] = [
{ key: 'amc_start', label: 'AMC / renewal start' },
]
/** The checklist for a module (owner-editable per module via setting; global default; code fallback). */
export async function milestoneTemplate(db: DB, moduleCode?: string): Promise<TemplateEntry[]> {
const parse = (value: string): TemplateEntry[] | undefined => {
function parseTemplate(value: string): TemplateEntry[] | undefined {
try {
const p = JSON.parse(value) as TemplateEntry[]
return Array.isArray(p) && p.length > 0 ? p : undefined
} catch { return undefined }
}
}
/**
* The checklist for a module: a shared "front" (same for every module enquiry through
* quotation-approved) followed by a per-module "tail" (owner-editable per module via
* setting; global default; code fallback). Composed as [...front, ...tail].
*/
export async function milestoneTemplate(db: DB, moduleCode?: string): Promise<TemplateEntry[]> {
const frontRow = await db.get<{ value: string }>(
`SELECT value FROM setting WHERE key='project.milestone_template.front'`)
const front = (frontRow !== undefined ? parseTemplate(frontRow.value) : undefined) ?? FRONT_FALLBACK
let tail: TemplateEntry[] | 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
tail = perModule !== undefined ? parseTemplate(perModule.value) : undefined
}
if (tail === undefined) {
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
tail = row !== undefined ? parseTemplate(row.value) : undefined
}
tail ??= TAIL_FALLBACK
return [...front, ...tail]
}
/**

@ -86,22 +86,23 @@ export async function seedIfEmpty(db: DB): Promise<void> {
}
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' },
// Onboarding milestone template (D22), composed model: a shared "front" (same for every
// module — enquiry through quotation-approved) plus a per-module "tail". Config over code
// — owner-editable; a project can also add its own milestones. Order here is display order.
const MILESTONE_TEMPLATE_FRONT = JSON.stringify([
{ key: 'enquiry', label: 'Enquiry received' },
{ key: 'visit_meeting', label: 'Visit / meeting scheduled' },
{ key: 'quotation_sent', label: 'Quotation sent' },
{ key: 'quotation_approved', label: 'Quotation approved' },
])
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 })
const seededFront = (await db.run(
INSERT_SETTING_SQL, 'project.milestone_template.front', MILESTONE_TEMPLATE_FRONT)).changes
if (seededFront > 0) {
await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template.front', undefined, { set: true })
}
// Per-module onboarding templates (config over code). SMS/RTGS/Mobile App differ; each ends
// with advance + balance payment so a payment tick maps cleanly on migration.
// Per-module onboarding tails (config over code). SMS/RTGS/Mobile App differ; each ends
// with go-live + payment received so a payment tick maps cleanly on migration.
const PER_MODULE_TEMPLATES: Record<string, { key: string; label: string }[]> = {
SMS: [
{ key: 'document_collection', label: 'Document collection' },
@ -111,8 +112,7 @@ export async function seedIfEmpty(db: DB): Promise<void> {
{ key: 'testing', label: 'Testing' },
{ key: 'training', label: 'Training' },
{ key: 'go_live', label: 'Go-live' },
{ key: 'advance_payment', label: 'Advance payment' },
{ key: 'balance_payment', label: 'Balance payment' },
{ key: 'payment_received', label: 'Payment received' },
],
RTGS: [
{ key: 'document_collection', label: 'Document collection' },
@ -122,8 +122,7 @@ export async function seedIfEmpty(db: DB): Promise<void> {
{ key: 'testing', label: 'Testing' },
{ key: 'training', label: 'Training' },
{ key: 'go_live', label: 'Go-live' },
{ key: 'advance_payment', label: 'Advance payment' },
{ key: 'balance_payment', label: 'Balance payment' },
{ key: 'payment_received', label: 'Payment received' },
],
MOBILEAPP: [
{ key: 'requirement_setup', label: 'Requirement & setup' },
@ -133,8 +132,7 @@ export async function seedIfEmpty(db: DB): Promise<void> {
{ key: 'testing', label: 'Testing' },
{ key: 'training', label: 'Training' },
{ key: 'go_live', label: 'Go-live' },
{ key: 'advance_payment', label: 'Advance payment' },
{ key: 'balance_payment', label: 'Balance payment' },
{ key: 'payment_received', label: 'Payment received' },
],
}
let seededPerModule = 0

@ -1,13 +1,16 @@
// apps/hq/test/migrate-onboarding-templates.test.ts — Client Detail redesign task 5: one-time
// migration swapping seeded modules (SMS/RTGS/MobileApp) from the old generic onboarding
// checklist onto their new per-module templates, carrying mapped ticks (done/done_on/payment_id).
// checklist onto their new composed (front + per-module tail) templates, carrying mapped
// ticks (done/done_on/payment_id).
import { describe, it, expect, beforeEach } from 'vitest'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule } from '../src/repos-modules'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
import { listProjectMilestones } from '../src/repos-milestones'
import { listProjectMilestones, TAIL_FALLBACK } from '../src/repos-milestones'
const FRONT_KEYS = ['enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved']
describe('onboarding template migration', () => {
let db: DB
@ -17,7 +20,7 @@ describe('onboarding template migration', () => {
const client = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
const mod = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' })
// assignModule auto-seeds the NEW per-module template (task 2/4) — wipe it so the test
// assignModule auto-seeds the NEW composed template (task 2/4) — wipe it so the test
// can plant the OLD generic rows a legacy project would actually have on disk.
await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cm.id)
return cm.id
@ -37,7 +40,7 @@ describe('onboarding template migration', () => {
}
}
it('swaps an SMS project to the 9-step list carrying installed+go_live ticks', async () => {
it('swaps an SMS project to the composed front+tail list carrying installed+go_live ticks', async () => {
const cmId = await makeSmsClientModule()
await seedOldGenericRows(cmId)
@ -48,8 +51,9 @@ describe('onboarding template migration', () => {
const ms = await listProjectMilestones(db, cmId)
expect(ms.map((m) => m.key)).toEqual([
...FRONT_KEYS,
'document_collection', 'dlt_registration', 'account_creation', 'installation',
'testing', 'training', 'go_live', 'advance_payment', 'balance_payment',
'testing', 'training', 'go_live', 'payment_received',
])
const installation = ms.find((m) => m.key === 'installation')!
expect(installation.done).toBe(true)
@ -58,10 +62,11 @@ describe('onboarding template migration', () => {
const goLive = ms.find((m) => m.key === 'go_live')!
expect(goLive.done).toBe(true)
expect(goLive.doneOn).toBe('2026-04-02')
// untouched (never ticked) steps stay pending
// untouched (never ticked) steps stay pending, including the newly-prepended front
expect(ms.find((m) => m.key === 'enquiry')!.done).toBe(false)
expect(ms.find((m) => m.key === 'quotation_approved')!.done).toBe(false)
expect(ms.find((m) => m.key === 'document_collection')!.done).toBe(false)
expect(ms.find((m) => m.key === 'advance_payment')!.done).toBe(false)
expect(ms.find((m) => m.key === 'balance_payment')!.done).toBe(false)
expect(ms.find((m) => m.key === 'payment_received')!.done).toBe(false)
// old setup_done / amc_start gone entirely
expect(ms.some((m) => m.key === 'setup_done' || m.key === 'amc_start')).toBe(false)
@ -74,7 +79,7 @@ describe('onboarding template migration', () => {
expect(audits[0]!.after_json).toContain('installation')
})
it('carries a done payment_id along with the mapped tick', async () => {
it('carries a done payment_id along with the mapped tick (advance_paid -> payment_received)', async () => {
const cmId = await makeSmsClientModule()
const client = await db.get<{ client_id: string }>(
`SELECT client_id FROM client_module WHERE id=?`, cmId,
@ -93,10 +98,25 @@ describe('onboarding template migration', () => {
await migrateOnboardingTemplates(db)
const ms = await listProjectMilestones(db, cmId)
const advance = ms.find((m) => m.key === 'advance_payment')!
expect(advance.done).toBe(true)
expect(advance.doneOn).toBe('2026-03-01')
expect(advance.paymentId).toBe(paymentId)
const paymentReceived = ms.find((m) => m.key === 'payment_received')!
expect(paymentReceived.done).toBe(true)
expect(paymentReceived.doneOn).toBe('2026-03-01')
expect(paymentReceived.paymentId).toBe(paymentId)
})
it('balance_paid also maps to payment_received', async () => {
const cmId = await makeSmsClientModule()
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort)
VALUES (?,?,?,?,?,?,?)`,
'm0', cmId, 'balance_paid', 'balance_paid', 1, '2026-04-10', 0,
)
await migrateOnboardingTemplates(db)
const ms = await listProjectMilestones(db, cmId)
const paymentReceived = ms.find((m) => m.key === 'payment_received')!
expect(paymentReceived.done).toBe(true)
expect(paymentReceived.doneOn).toBe('2026-04-10')
})
it('is idempotent (second run changes nothing new)', async () => {
@ -151,8 +171,9 @@ describe('onboarding template migration', () => {
// Verify the dropped keys are gone from the migrated template
const ms = await listProjectMilestones(db, cmId)
expect(ms.map((m) => m.key)).toEqual([
...FRONT_KEYS,
'document_collection', 'dlt_registration', 'account_creation', 'installation',
'testing', 'training', 'go_live', 'advance_payment', 'balance_payment',
'testing', 'training', 'go_live', 'payment_received',
])
expect(ms.some((m) => m.key === 'amc_start' || m.key === 'setup_done')).toBe(false)
@ -167,11 +188,10 @@ describe('onboarding template migration', () => {
const mod = await createModule(db, 'u1', { code: 'NOTEMPLATE', name: 'No Template Module' })
const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' })
// This module has no project.milestone_template:NOTEMPLATE setting, so ensureProjectMilestones
// fell back to the global generic template — the OLD generic rows themselves.
// composed the front with the code TAIL_FALLBACK (no global 'project.milestone_template' is
// seeded any more either).
const before = await listProjectMilestones(db, cm.id)
expect(before.map((m) => m.key)).toEqual([
'advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start',
])
expect(before.map((m) => m.key)).toEqual([...FRONT_KEYS, ...TAIL_FALLBACK.map((t) => t.key)])
const res = await migrateOnboardingTemplates(db)
expect(res.projects).toBe(0)

@ -17,13 +17,13 @@ async function world() {
}
describe('milestone payment_id link (payment-step tick)', () => {
it('linking a payment to advance_payment stores payment_id and mirrors received_on', async () => {
it('linking a payment to payment_received stores payment_id and mirrors received_on', async () => {
const { db, clientId, cmId } = await world()
const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
})
await setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id)
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'advance_payment')!
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'payment_received')!
expect(ms.done).toBe(true)
expect(ms.doneOn).toBe('2026-05-01')
expect(ms.paymentId).toBe(pay.payment.id)
@ -36,7 +36,7 @@ describe('milestone payment_id link (payment-step tick)', () => {
clientId: other.id, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 500_00,
})
await expect(
setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id),
setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id),
).rejects.toThrow(/payment/i)
})
@ -45,9 +45,9 @@ describe('milestone payment_id link (payment-step tick)', () => {
const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
})
await setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id)
await setMilestone(db, 'u1', cmId, 'advance_payment', false)
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'advance_payment')!
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
await setMilestone(db, 'u1', cmId, 'payment_received', false)
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'payment_received')!
expect(ms.done).toBe(false)
expect(ms.doneOn).toBeNull()
expect(ms.paymentId).toBeNull()
@ -58,8 +58,8 @@ describe('milestone payment_id link (payment-step tick)', () => {
const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
})
await setMilestone(db, 'u1', cmId, 'balance_payment', true, '2026-05-10', pay.payment.id)
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'balance_payment')!
await setMilestone(db, 'u1', cmId, 'payment_received', true, '2026-05-10', pay.payment.id)
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'payment_received')!
expect(ms.doneOn).toBe('2026-05-10')
expect(ms.paymentId).toBe(pay.payment.id)
})

@ -16,6 +16,13 @@ async function world() {
}
describe('milestone → status auto-drive (D22 status line)', () => {
it('ticking quotation_approved advances status to ordered', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'quotation_approved', true)
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('ordered')
})
it('ticking installation advances status to installed', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'installation', true)

@ -1,30 +1,58 @@
// apps/hq/test/milestones-templates.test.ts — per-module onboarding template resolution.
// apps/hq/test/milestones-templates.test.ts — composed (front + per-module tail) onboarding
// template resolution.
import { describe, it, expect, beforeEach } from 'vitest'
import { openDb, type DB } from '../src/db'
import { milestoneTemplate } from '../src/repos-milestones'
import { milestoneTemplate, FRONT_FALLBACK, TAIL_FALLBACK } from '../src/repos-milestones'
describe('per-module milestone templates', () => {
describe('composed milestone templates (front + tail)', () => {
let db: DB
beforeEach(() => { db = openDb(':memory:') })
it('falls back to the global template when no module-specific one exists', async () => {
it('falls back to the code FRONT_FALLBACK + TAIL_FALLBACK when nothing is configured', async () => {
expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
expect(await milestoneTemplate(db)).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
})
it('starts with the 4 front keys then the SMS tail', async () => {
const keys = (await milestoneTemplate(db, 'SMS')).map((e) => e.key)
expect(keys.slice(0, 4)).toEqual(FRONT_FALLBACK.map((f) => f.key))
})
it('uses the configured front for every module and with no moduleCode', async () => {
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template.front', ?)`,
JSON.stringify([{ key: 'f1', label: 'F1' }]))
expect((await milestoneTemplate(db, 'SMS'))[0]).toEqual({ key: 'f1', label: 'F1' })
expect((await milestoneTemplate(db))[0]).toEqual({ key: 'f1', label: 'F1' })
})
it('tail: falls back to the global tail 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' }])
expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, { key: 'a', label: 'A' }])
})
it('prefers the module-specific template over the global one', async () => {
it('tail: 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' }])
expect(await milestoneTemplate(db, 'SMS'))
.toEqual([...FRONT_FALLBACK, { key: 'doc', label: 'Document collection' }])
// A different module still gets the global tail.
expect(await milestoneTemplate(db, 'RTGS')).toEqual([...FRONT_FALLBACK, { key: 'a', label: 'A' }])
})
it('with no moduleCode: front + global tail when set, else TAIL_FALLBACK', async () => {
expect(await milestoneTemplate(db)).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
JSON.stringify([{ key: 'a', label: 'A' }]))
expect(await milestoneTemplate(db)).toEqual([...FRONT_FALLBACK, { 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')
it('a malformed or empty per-module tail setting is treated as absent', async () => {
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`, 'not json')
expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
await db.run(`UPDATE setting SET value=? WHERE key='project.milestone_template:SMS'`, '[]')
expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
})
})

@ -21,10 +21,13 @@ async function world() {
}
describe('project milestones (D22)', () => {
it('seeds the standard checklist on assignment, in template order', async () => {
it('seeds the standard checklist on assignment, in template order (front + tail)', 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.map((m) => m.key)).toEqual([
'enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved',
'advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start',
])
expect(ms.every((m) => !m.done && m.doneOn === null)).toBe(true)
})
@ -45,7 +48,7 @@ describe('project milestones (D22)', () => {
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).toHaveLength(11) // 4 front + 6 tail + 1 custom
expect(ms[ms.length - 1]!.label).toBe('AWS region confirmed')
await expect(addProjectMilestone(db, 'u1', p1.id, ' ')).rejects.toThrow(/label/)
})
@ -59,9 +62,9 @@ describe('project milestones (D22)', () => {
)
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(0)
await ensureProjectMilestones(db, 'imported-cm')
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(6)
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(10) // 4 front + 6 tail
await ensureProjectMilestones(db, 'imported-cm') // second call adds nothing
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(6)
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(10)
})
it('boards the whole book and drills into who is pending a milestone', async () => {

@ -3,23 +3,36 @@ import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { milestoneTemplate } from '../src/repos-milestones'
describe('seed per-module onboarding templates', () => {
const FRONT_KEYS = ['enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved']
describe('seed onboarding templates (composed front + per-module tail)', () => {
let db: DB
beforeEach(async () => { db = openDb(':memory:'); await seedIfEmpty(db) })
it('seeds the SMS 9-step list', async () => {
it('composes the shared front with the SMS tail', async () => {
const keys = (await milestoneTemplate(db, 'SMS')).map((e) => e.key)
expect(keys).toEqual([
...FRONT_KEYS,
'document_collection', 'dlt_registration', 'account_creation', 'installation',
'testing', 'training', 'go_live', 'advance_payment', 'balance_payment',
'testing', 'training', 'go_live', 'payment_received',
])
})
it('seeds the RTGS list ending in payment steps', async () => {
it('composes the shared front with the RTGS tail ending in go-live + payment received', async () => {
const keys = (await milestoneTemplate(db, 'RTGS')).map((e) => e.key)
expect(keys).toEqual([
...FRONT_KEYS,
'document_collection', 'server_checkup', 'setup_configuration', 'installation',
'testing', 'training', 'go_live', 'advance_payment', 'balance_payment',
'testing', 'training', 'go_live', 'payment_received',
])
})
it('composes the shared front with the Mobile App tail', async () => {
const keys = (await milestoneTemplate(db, 'MOBILEAPP')).map((e) => e.key)
expect(keys).toEqual([
...FRONT_KEYS,
'requirement_setup', 'configuration', 'data_import', 'installation',
'testing', 'training', 'go_live', 'payment_received',
])
})
})

Loading…
Cancel
Save