You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/migrate-onboarding-template...

151 lines
8.3 KiB
TypeScript

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