From f1a7eac9ddc26d265a7156bcf3970afae7f44a0f Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 03:49:48 +0530 Subject: [PATCH] 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) --- apps/hq/src/migrate-onboarding-templates.ts | 3 +- apps/hq/src/repos-milestones.ts | 50 ++++++++++++----- apps/hq/src/seed.ts | 38 ++++++------- .../test/migrate-onboarding-templates.test.ts | 56 +++++++++++++------ apps/hq/test/milestone-payment.test.ts | 18 +++--- apps/hq/test/milestone-status.test.ts | 7 +++ apps/hq/test/milestones-templates.test.ts | 52 +++++++++++++---- apps/hq/test/milestones.test.ts | 13 +++-- apps/hq/test/seed-templates.test.ts | 23 ++++++-- 9 files changed, 176 insertions(+), 84 deletions(-) diff --git a/apps/hq/src/migrate-onboarding-templates.ts b/apps/hq/src/migrate-onboarding-templates.ts index 8245c82..6c8ba27 100644 --- a/apps/hq/src/migrate-onboarding-templates.ts +++ b/apps/hq/src/migrate-onboarding-templates.ts @@ -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 = { 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', } diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index 4623571..931d23b 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -24,7 +24,7 @@ const STATUS_RANK: Record = { quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7, } const STEP_STATUS: Record = { - 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 { @@ -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). */ +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 { - const parse = (value: string): TemplateEntry[] | undefined => { - try { - const p = JSON.parse(value) as TemplateEntry[] - return Array.isArray(p) && p.length > 0 ? p : undefined - } catch { return undefined } - } + 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 } - 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 + if (tail === undefined) { + const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`) + tail = row !== undefined ? parseTemplate(row.value) : undefined + } + tail ??= TAIL_FALLBACK + + return [...front, ...tail] } /** diff --git a/apps/hq/src/seed.ts b/apps/hq/src/seed.ts index e8fa5ea..24f7072 100644 --- a/apps/hq/src/seed.ts +++ b/apps/hq/src/seed.ts @@ -86,22 +86,23 @@ 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' }, + // 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 = { SMS: [ { key: 'document_collection', label: 'Document collection' }, @@ -111,8 +112,7 @@ export async function seedIfEmpty(db: DB): Promise { { 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 { { 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 { { 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 diff --git a/apps/hq/test/migrate-onboarding-templates.test.ts b/apps/hq/test/migrate-onboarding-templates.test.ts index 8873af4..69500b0 100644 --- a/apps/hq/test/migrate-onboarding-templates.test.ts +++ b/apps/hq/test/migrate-onboarding-templates.test.ts @@ -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) diff --git a/apps/hq/test/milestone-payment.test.ts b/apps/hq/test/milestone-payment.test.ts index c2e6bd2..9ef8510 100644 --- a/apps/hq/test/milestone-payment.test.ts +++ b/apps/hq/test/milestone-payment.test.ts @@ -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) }) diff --git a/apps/hq/test/milestone-status.test.ts b/apps/hq/test/milestone-status.test.ts index 924137b..abeaca7 100644 --- a/apps/hq/test/milestone-status.test.ts +++ b/apps/hq/test/milestone-status.test.ts @@ -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) diff --git a/apps/hq/test/milestones-templates.test.ts b/apps/hq/test/milestones-templates.test.ts index eaaac0b..ab84f19 100644 --- a/apps/hq/test/milestones-templates.test.ts +++ b/apps/hq/test/milestones-templates.test.ts @@ -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]) }) }) diff --git a/apps/hq/test/milestones.test.ts b/apps/hq/test/milestones.test.ts index cf1a994..65c5d1b 100644 --- a/apps/hq/test/milestones.test.ts +++ b/apps/hq/test/milestones.test.ts @@ -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 () => { diff --git a/apps/hq/test/seed-templates.test.ts b/apps/hq/test/seed-templates.test.ts index a5f62b4..a09fb93 100644 --- a/apps/hq/test/seed-templates.test.ts +++ b/apps/hq/test/seed-templates.test.ts @@ -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', ]) }) })