// 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 + document_id) so onboarding history is not lost. Ad-hoc // project-specific steps ("+ Add step" / `addProjectMilestone`, `custom_*` keys) are never // template-mapped — they are preserved verbatim, appended after the template block, so a // staffer's own checklist entries survive the swap too (see `preserved`, below). import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import { FRONT_FALLBACK, TAIL_FALLBACK, 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: 'payment_received', balance_paid: 'payment_received', advance_payment: 'payment_received', balance_payment: 'payment_received', setup_done: 'setup_configuration', } /** * Old generic (pre-redesign) keys that intentionally have NO forward mapping — e.g. * `amc_start` (AMC/renewal is tracked elsewhere now). These are code-level defaults from * FRONT_FALLBACK/TAIL_FALLBACK, not ad-hoc project-specific steps (those always use * `addProjectMilestone`'s `custom_*` key pattern), so a ticked one still counts as `dropped` * — never `preserved` — same as before this fix. */ const KNOWN_LEGACY_KEYS = new Set([...FRONT_FALLBACK, ...TAIL_FALLBACK].map((t) => t.key)) interface OldRow { key: string; label: string; done: number; done_on: string | null payment_id: string | null; document_id: string | null } /** * When two+ old keys collapse onto the SAME new key (e.g. `advance_paid` + `balance_paid` * both -> `payment_received`), pick which tick survives, deterministically: prefer whichever * carries a real linked payment (`payment_id`) — that link is the more "meaningful" tick; * if both or neither do, prefer the one with the LATEST `done_on` (the more recent progress). * The loser is never silently overwritten — the caller counts it (`collapsed`) and the full * pre-migration row set goes into the audit `before` payload, so it's always reconstructible. */ function pickSurvivingTick(a: OldRow, b: OldRow): OldRow { const aHasPayment = a.payment_id !== null && a.payment_id !== '' const bHasPayment = b.payment_id !== null && b.payment_id !== '' if (aHasPayment !== bHasPayment) return aHasPayment ? a : b return (a.done_on ?? '') >= (b.done_on ?? '') ? a : b } /** * 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/document_id) forward under their * new key. Unmapped old GENERIC ticks (e.g. `amc_start`, or `setup_done` for a module whose * new template has no matching step) are dropped (and counted). Ad-hoc project-specific rows * (any key that isn't a template key, isn't in KEY_MAP, and isn't a known old generic key) are * preserved unchanged and re-appended after the template block (counted in `preserved`). * 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; preserved: number; collapsed: 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, preserved = 0, collapsed = 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, label, done, done_on, payment_id, document_id FROM project_milestone WHERE client_module_id=? ORDER BY sort`, cmId) // Build the carried tick-state keyed by NEW key, and set aside ad-hoc custom rows. const carriedState = new Map() const customRows: OldRow[] = [] for (const r of oldRows) { const isAdHocCustom = !templateKeys.has(r.key) && KEY_MAP[r.key] === undefined && !KNOWN_LEGACY_KEYS.has(r.key) if (isAdHocCustom) { customRows.push(r); continue } const target = templateKeys.has(r.key) ? r.key : KEY_MAP[r.key] if (target !== undefined && templateKeys.has(target)) { if (r.done === 1) { const existing = carriedState.get(target) if (existing === undefined) { carriedState.set(target, r) } else { // Two old keys collapse onto the same new key — deterministically pick a // survivor (never a silent Map.set overwrite) and count the one that loses. carriedState.set(target, pickSurvivingTick(existing, r)) collapsed++ } } } else if (r.done === 1) { dropped++ } } // Idempotency: if the project's keys already ARE exactly the template, skip. Computed // over NON-CUSTOM rows only (regression fix): the old check flagged `alreadyNew = false` // for ANY row whose key isn't a template key — including deliberately-preserved `custom_*` // rows (the `addProjectMilestone` "+ Add step" key pattern) — so a project with even one // ad-hoc step was never "identical" and got DELETE-and-rebuilt on every single run (new // row ids, a fresh audit row each time), contradicting "safe to re-run / a second run // does nothing". Using the same discriminator the preservation logic above relies on. const isCustom = (k: string): boolean => k.startsWith('custom_') const templateRowKeys = new Set(oldRows.filter((r) => !isCustom(r.key)).map((r) => r.key)) const identical = templateRowKeys.size === templateKeys.size && [...templateKeys].every((k) => templateRowKeys.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, document_id, sort) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, uuidv7(), cmId, t.key, t.label, c !== undefined ? 1 : 0, c?.done_on ?? null, c?.payment_id ?? null, c?.document_id ?? null, sort, ) if (c !== undefined) carried++ sort++ } // Ad-hoc project-specific steps survive the swap unchanged, appended after the // template (sort continues past it) so this project's own checklist entries — and // whatever tick state they carry — are never silently lost. for (const c of customRows) { await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, document_id, sort) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, uuidv7(), cmId, c.key, c.label, c.done, c.done_on, c.payment_id, c.document_id, sort, ) sort++ } preserved += customRows.length // The full pre-migration row set is recorded as `before` (not just a summary) so // ANY discarded tick — dropped, or the losing side of a collapse — is reconstructible // from the append-only audit log, never silently unrecoverable (FIX C). await writeAudit(db, 'system', 'migrate_onboarding', 'client_module', cmId, oldRows, { module: code, carried: [...carriedState.keys()], preserved: customRows.map((c) => c.key) }) }) } } return { projects, carried, dropped, preserved, collapsed } }