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.
151 lines
8.3 KiB
TypeScript
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 }
|
|
}
|