diff --git a/apps/hq/scripts/migrate-onboarding-templates.ts b/apps/hq/scripts/migrate-onboarding-templates.ts new file mode 100644 index 0000000..c255d8a --- /dev/null +++ b/apps/hq/scripts/migrate-onboarding-templates.ts @@ -0,0 +1,19 @@ +// apps/hq/scripts/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time +// migration swapping existing SMS/RTGS/MobileApp projects off the old generic onboarding +// checklist onto their new per-module template, carrying mapped ticks. Idempotent — safe +// to re-run (a second run does nothing). +// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/migrate-onboarding-templates.ts +import { openDb } from '../src/db' +import { openPgDb } from '../src/db-pg' +import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' + +async function main() { + const pgUrl = process.env['DATABASE_URL'] ?? '' + const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) + + const res = await migrateOnboardingTemplates(db) + // eslint-disable-next-line no-console + console.log(`Onboarding template migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped.`) +} + +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/apps/hq/src/migrate-onboarding-templates.ts b/apps/hq/src/migrate-onboarding-templates.ts new file mode 100644 index 0000000..8245c82 --- /dev/null +++ b/apps/hq/src/migrate-onboarding-templates.ts @@ -0,0 +1,81 @@ +// apps/hq/src/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time +// migration swapping seeded modules (SMS/RTGS/MobileApp) off the old generic onboarding +// checklist onto their new per-module template (task 2), carrying mapped ticks +// (done + done_on + payment_id) so onboarding history is not lost. +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import { milestoneTemplate } from './repos-milestones' +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', + setup_done: 'setup_configuration', +} + +interface OldRow { key: string; done: number; done_on: string | null; payment_id: string | null } + +/** + * Idempotent: for every client_module whose module has a per-module onboarding template + * (a `project.milestone_template:` setting — task 2), rebuild its milestone rows + * from that template, carrying mapped ticks (done/done_on/payment_id) forward under their + * new key. Unmapped old ticks (e.g. `amc_start`, or `setup_done` for a module whose new + * template has no matching step) are dropped (and counted). Client_modules whose module + * has no per-module template are left completely untouched. + */ +export async function migrateOnboardingTemplates( + db: DB, +): Promise<{ projects: number; carried: number; dropped: number }> { + // Which module codes have a per-module template setting? + const codes = (await db.all<{ key: string }>( + `SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`)) + .map((r) => r.key.slice('project.milestone_template:'.length)) + let projects = 0, carried = 0, dropped = 0 + for (const code of codes) { + const template = await milestoneTemplate(db, code) + const templateKeys = new Set(template.map((t) => t.key)) + const cms = await db.all<{ id: string }>( + `SELECT cm.id FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE m.code=?`, code) + for (const { id: cmId } of cms) { + const oldRows = await db.all( + `SELECT key, done, done_on, payment_id FROM project_milestone WHERE client_module_id=?`, cmId) + // Build the carried tick-state keyed by NEW key. + const carriedState = new Map() + let alreadyNew = true + for (const r of oldRows) { + if (!templateKeys.has(r.key)) alreadyNew = false + const target = templateKeys.has(r.key) ? r.key : KEY_MAP[r.key] + if (target !== undefined && templateKeys.has(target)) { + if (r.done === 1) carriedState.set(target, r) + } else if (r.done === 1) { + dropped++ + } + } + // Idempotency: if the project's keys already ARE exactly the template, skip. + const oldKeySet = new Set(oldRows.map((r) => r.key)) + const identical = alreadyNew && oldKeySet.size === templateKeys.size + && [...templateKeys].every((k) => oldKeySet.has(k)) + if (identical) continue + projects++ + await db.transaction(async () => { + await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cmId) + let sort = 0 + for (const t of template) { + const c = carriedState.get(t.key) + await db.run( + `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + uuidv7(), cmId, t.key, t.label, + c !== undefined ? 1 : 0, c?.done_on ?? null, c?.payment_id ?? null, sort, + ) + if (c !== undefined) carried++ + sort++ + } + await writeAudit(db, 'system', 'migrate_onboarding', 'client_module', cmId, undefined, + { module: code, carried: [...carriedState.keys()] }) + }) + } + } + return { projects, carried, dropped } +} diff --git a/apps/hq/test/migrate-onboarding-templates.test.ts b/apps/hq/test/migrate-onboarding-templates.test.ts new file mode 100644 index 0000000..6d34d15 --- /dev/null +++ b/apps/hq/test/migrate-onboarding-templates.test.ts @@ -0,0 +1,141 @@ +// 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). +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' + +describe('onboarding template migration', () => { + let db: DB + beforeEach(async () => { db = openDb(':memory:'); await seedIfEmpty(db) }) + + async function makeSmsClientModule(): Promise { + 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 + // 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 + } + + async function seedOldGenericRows(cmId: string): Promise { + const rows: [string, number, string | null][] = [ + ['advance_paid', 0, null], ['setup_done', 0, null], ['installed', 1, '2026-03-25'], + ['go_live', 1, '2026-04-02'], ['balance_paid', 0, null], ['amc_start', 0, null], + ] + for (const [i, r] of rows.entries()) { + await db.run( + `INSERT INTO project_milestone (id,client_module_id,key,label,done,done_on,sort) + VALUES (?,?,?,?,?,?,?)`, + `m${i}`, cmId, r[0], String(r[0]), r[1], r[2], i, + ) + } + } + + it('swaps an SMS project to the 9-step list carrying installed+go_live ticks', async () => { + const cmId = await makeSmsClientModule() + await seedOldGenericRows(cmId) + + const res = await migrateOnboardingTemplates(db) + expect(res.projects).toBe(1) + expect(res.carried).toBe(2) // installed -> installation, go_live -> go_live + expect(res.dropped).toBe(0) // the two dropped keys (setup_done, amc_start) were both undone + + const ms = await listProjectMilestones(db, cmId) + expect(ms.map((m) => m.key)).toEqual([ + 'document_collection', 'dlt_registration', 'account_creation', 'installation', + 'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', + ]) + const installation = ms.find((m) => m.key === 'installation')! + expect(installation.done).toBe(true) + expect(installation.doneOn).toBe('2026-03-25') + expect(installation.paymentId).toBeNull() + 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 + 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) + // old setup_done / amc_start gone entirely + expect(ms.some((m) => m.key === 'setup_done' || m.key === 'amc_start')).toBe(false) + + // audited + const audits = await db.all<{ entity_id: string; after_json: string | null }>( + `SELECT entity_id, after_json FROM audit_log WHERE action='migrate_onboarding'`, + ) + expect(audits).toHaveLength(1) + expect(audits[0]!.entity_id).toBe(cmId) + expect(audits[0]!.after_json).toContain('installation') + }) + + it('carries a done payment_id along with the mapped tick', async () => { + const cmId = await makeSmsClientModule() + const client = await db.get<{ client_id: string }>( + `SELECT client_id FROM client_module WHERE id=?`, cmId, + ) + const paymentId = 'pay-1' + await db.run( + `INSERT INTO payment (id, client_id, received_on, mode, amount_paise, created_by, created_at) + VALUES (?, ?, '2026-03-01', 'bank', 500000, 'u1', '2026-03-01T00:00:00.000Z')`, + paymentId, client!.client_id, + ) + await db.run( + `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort) + VALUES (?,?,?,?,?,?,?,?)`, + 'm0', cmId, 'advance_paid', 'advance_paid', 1, '2026-03-01', paymentId, 0, + ) + + 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) + }) + + it('is idempotent (second run changes nothing new)', async () => { + const cmId = await makeSmsClientModule() + await seedOldGenericRows(cmId) + + const first = await migrateOnboardingTemplates(db) + expect(first.projects).toBe(1) + const afterFirst = await listProjectMilestones(db, cmId) + + const second = await migrateOnboardingTemplates(db) + expect(second.projects).toBe(0) + expect(second.carried).toBe(0) + expect(second.dropped).toBe(0) + const afterSecond = await listProjectMilestones(db, cmId) + expect(afterSecond).toEqual(afterFirst) + + // no extra audit rows written on the no-op second pass + const audits = await db.all<{ n: number }>( + `SELECT COUNT(*) AS n FROM audit_log WHERE action='migrate_onboarding'`, + ) + expect((audits[0] as unknown as { n: number }).n).toBe(1) + }) + + it('leaves a module without a per-module template untouched', async () => { + const client = await createClient(db, 'u1', { name: 'Other Society', stateCode: '32' }) + 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. + 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', + ]) + + const res = await migrateOnboardingTemplates(db) + expect(res.projects).toBe(0) + + const after = await listProjectMilestones(db, cm.id) + expect(after).toEqual(before) + }) +})