feat(onboarding): one-time migration swapping seeded modules to new templates (ticks preserved)

Idempotent migrateOnboardingTemplates(db): for every client_module whose module
has a per-module onboarding template (task 2), rebuilds its milestone rows from
that template, carrying mapped ticks (installed->installation, go_live->go_live,
advance_paid->advance_payment, balance_paid->balance_payment, setup_done->
setup_configuration when present) with done_on and payment_id intact. Unmapped
ticks (amc_start, or setup_done where the new template has no matching step) are
dropped and counted. Audited per project; a second run is a no-op via an
identical-key-set short-circuit. Adds the CLI wrapper (mirrors
apply-name-fixes.ts's open-DB idiom, Postgres or SQLite) for the real one-time
run against production data.

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

@ -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) })

@ -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<string, string> = {
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:<CODE>` 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<OldRow>(
`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<string, OldRow>()
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 }
}

@ -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<string> {
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<void> {
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)
})
})
Loading…
Cancel
Save