import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import { setSetting } from './repos-reminders' import type { DB } from './db' /** * Onboarding tracker (D22), COMPOSED front + tail model. Each client_module (a "project") * carries a checklist built as [...front, ...tail]: * - the **front** is shared by every module — enquiry → visit → quotation sent → quotation * approved — from the `project.milestone_template.front` setting (code: FRONT_FALLBACK); * - the **tail** is module-specific — the `project.milestone_template:` setting, else * the global `project.milestone_template`, else the code TAIL_FALLBACK. * Both are owner-editable settings (config over code); a project may also add its own steps. * Ticking a step stamps the date, can advance the coarse client_module.status (never * downgrades), and can carry a link: a payment step to the real `payment` row, the quotation * step to the `document` (quotation / proforma) that was sent. Reportable across the whole * book: "who's pending go-live", "who hasn't paid". */ export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number paymentId: string | null; documentId: string | null } interface MilestoneRow { key: string; label: string; done: number; done_on: string | null; sort: number payment_id: string | null; document_id: string | null } export interface TemplateEntry { key: string; label: string } /** Ticking a step can advance the coarse client_module.status — never downgrade. Ranks match * the status CHECK order: quoted = { quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7, } const STEP_STATUS: Record = { quotation_approved: 'ordered', installation: 'installed', training: 'trained', go_live: 'live', // Pre-ship audit FIX E: TAIL_FALLBACK (used by any module with no seeded/owner-configured // tail — e.g. WHATSAPP, ATM, AMC) still uses the legacy 'installed' key, not the new // template vocabulary's 'installation'. Map it to the same target so those modules still // auto-advance status (and stamp installed_on, below) instead of getting stuck at 'quoted'. // TAIL_FALLBACK has no training-equivalent step and no payment key ever drove status, so // no other legacy key needs a mapping here. installed: 'installed', } /** * Also replaces the removed UI date-writers (Client Detail redesign dropped `RowDate` / * the summary-table date columns without a replacement): the first time status crosses * into 'installed' / 'trained', stamp client_module.installed_on / trained_on from the * triggering milestone's own done_on — never overwriting a date already on file (e.g. one * set by the APEX importer or a direct edit). */ async function advanceStatusForStep( db: DB, userId: string, clientModuleId: string, key: string, doneOn: string | null, ): Promise { const target = STEP_STATUS[key] if (target === undefined) return const cm = await db.get<{ status: string; installed_on: string | null; trained_on: string | null }>( `SELECT status, installed_on, trained_on FROM client_module WHERE id=?`, clientModuleId) if (cm === undefined) return if ((STATUS_RANK[cm.status] ?? 0) >= (STATUS_RANK[target] ?? 0)) return // never downgrade const sets = ['status=?'] const args: unknown[] = [target] const before: Record = { status: cm.status } const after: Record = { status: target } if (target === 'installed' && cm.installed_on === null && doneOn !== null) { sets.push('installed_on=?'); args.push(doneOn) before['installedOn'] = null; after['installedOn'] = doneOn } if (target === 'trained' && cm.trained_on === null && doneOn !== null) { sets.push('trained_on=?'); args.push(doneOn) before['trainedOn'] = null; after['trainedOn'] = doneOn } args.push(clientModuleId) await db.run(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`, ...args) await writeAudit(db, userId, 'update', 'client_module', clientModuleId, before, after) } /** The shared lead-in every module's checklist starts with (code default for the "front"). */ export const FRONT_FALLBACK: TemplateEntry[] = [ { key: 'enquiry', label: 'Enquiry received' }, { key: 'visit_meeting', label: 'Visit / meeting scheduled' }, { key: 'quotation_sent', label: 'Quotation sent' }, { key: 'quotation_approved', label: 'Quotation approved' }, ] /** Code default for the module-specific "tail" when nothing is configured. */ export const TAIL_FALLBACK: TemplateEntry[] = [ { key: 'advance_paid', label: 'Advance payment received' }, { key: 'setup_done', label: 'Setup / configuration done' }, { key: 'installed', label: 'Installation done' }, { key: 'go_live', label: 'Go-live' }, { key: 'balance_paid', label: 'Balance payment received' }, { key: 'amc_start', label: 'AMC / renewal start' }, ] function parseTemplate(value: string): TemplateEntry[] | undefined { try { const p = JSON.parse(value) as TemplateEntry[] return Array.isArray(p) && p.length > 0 ? p : undefined } catch { return undefined } } /** The shared "front" alone — setting row, else the code fallback. */ export async function getFrontTemplate(db: DB): Promise { const frontRow = await db.get<{ value: string }>( `SELECT value FROM setting WHERE key='project.milestone_template.front'`) return (frontRow !== undefined ? parseTemplate(frontRow.value) : undefined) ?? FRONT_FALLBACK } /** One module's "tail" alone (not composed with the front): its own per-module setting, * else the global tail setting, else the code fallback. `moduleCode` may be '' (no module * context) — resolves straight to the global/fallback tail. */ export async function getModuleTail(db: DB, moduleCode: string): Promise { let tail: TemplateEntry[] | undefined if (moduleCode !== '') { const perModule = await db.get<{ value: string }>( `SELECT value FROM setting WHERE key=?`, `project.milestone_template:${moduleCode}`) tail = perModule !== undefined ? parseTemplate(perModule.value) : undefined } if (tail === undefined) { const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`) tail = row !== undefined ? parseTemplate(row.value) : undefined } return tail ?? TAIL_FALLBACK } /** * The checklist for a module: a shared "front" (same for every module — enquiry through * quotation-approved) followed by a per-module "tail" (owner-editable per module via * setting; global default; code fallback). Composed as [...front, ...tail]. */ export async function milestoneTemplate(db: DB, moduleCode?: string): Promise { const front = await getFrontTemplate(db) const tail = await getModuleTail(db, moduleCode ?? '') return [...front, ...tail] } /** Make a valid step key from a label: lowercase, non-alphanumeric -> `_`, collapse repeats, trim `_`. */ function slugifyStepLabel(label: string): string { return label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '') } /** * Validate + normalize a template-editor PUT body's `steps` into `TemplateEntry[]`: a * non-empty array, every label non-blank after trim, every key unique. A missing/blank key * is derived by slugifying its label (bumping a numeric suffix on collision). Throws with a * clear message otherwise — the caller turns that into a 400. */ export function normalizeTemplateSteps(rawSteps: unknown): TemplateEntry[] { if (!Array.isArray(rawSteps) || rawSteps.length === 0) throw new Error('Provide at least one step') const used = new Set() const out: TemplateEntry[] = [] for (const raw of rawSteps) { const r = (typeof raw === 'object' && raw !== null ? raw : {}) as { key?: unknown; label?: unknown } const label = typeof r.label === 'string' ? r.label.trim() : '' if (label === '') throw new Error('Every step needs a non-blank label') let key = typeof r.key === 'string' ? r.key.trim() : '' if (key === '') { const base = slugifyStepLabel(label) || 'step' key = base let n = 2 while (used.has(key)) key = `${base}_${n++}` } if (used.has(key)) throw new Error(`Duplicate step key: "${key}"`) used.add(key) out.push({ key, label }) } return out } /** Every module code with its OWN configured tail setting (not the global fallback). */ async function configuredTailCodes(db: DB): Promise { const rows = await db.all<{ key: string }>(`SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`) return rows.map((r) => r.key.slice('project.milestone_template:'.length)) } /** Throws a clear 400-able error if any of `frontKeys` collides with a key in `tail`. * `tailLabel` identifies the tail in the message (a module code, or 'default'). */ function rejectCollision(frontKeys: Set, tail: TemplateEntry[], tailLabel: string): void { const hit = tail.find((t) => frontKeys.has(t.key)) if (hit !== undefined) { throw new Error(`Step key "${hit.key}" is used by both the front and the ${tailLabel} tail template — keys must be unique across the whole checklist`) } } /** * Owner editor: replace the shared front template. Config-over-code, audited via `setSetting`. * The front is composed with EVERY module's tail ([...front, ...tail]), so a front key must * not collide with any tail currently in play: the global/default tail, and every module that * has its own configured tail. */ export async function setFrontTemplate(db: DB, userId: string, rawSteps: unknown): Promise { const steps = normalizeTemplateSteps(rawSteps) const frontKeys = new Set(steps.map((s) => s.key)) rejectCollision(frontKeys, await getModuleTail(db, ''), 'default') for (const code of await configuredTailCodes(db)) { rejectCollision(frontKeys, await getModuleTail(db, code), code) } await setSetting(db, userId, 'project.milestone_template.front', JSON.stringify(steps)) return steps } /** * Owner editor: replace one module's tail template. Config-over-code, audited via `setSetting`. * This tail is always composed after the shared front, so its keys must not collide with it. */ export async function setModuleTail(db: DB, userId: string, moduleCode: string, rawSteps: unknown): Promise { const steps = normalizeTemplateSteps(rawSteps) const frontKeys = new Set((await getFrontTemplate(db)).map((f) => f.key)) rejectCollision(frontKeys, steps, moduleCode || 'default') await setSetting(db, userId, `project.milestone_template:${moduleCode}`, JSON.stringify(steps)) return steps } /** * Idempotently materialize the template's milestones for a project. New template rows are * added on later calls (so extending the standard set reaches existing projects); a * project's own custom milestones and any ticked state are never touched. */ export async function ensureProjectMilestones(db: DB, clientModuleId: string): Promise { const cm = await db.get<{ module_code: string }>( `SELECT m.code AS module_code FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE cm.id=?`, clientModuleId) const template = await milestoneTemplate(db, cm?.module_code) const existing = new Set( (await db.all<{ key: string }>(`SELECT key FROM project_milestone WHERE client_module_id=?`, clientModuleId)) .map((r) => r.key), ) await db.transaction(async () => { let sort = 0 for (const t of template) { if (!existing.has(t.key)) { await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) VALUES (?, ?, ?, ?, 0, NULL, ?)`, uuidv7(), clientModuleId, t.key, t.label, sort, ) // A template with a front/tail key collision (owner-editor bug, or a legacy setting // written before that was validated) would otherwise INSERT the same // (client_module_id, key) twice and violate the UNIQUE constraint mid-loop. existing.add(t.key) } sort++ } }) } export async function listProjectMilestones(db: DB, clientModuleId: string): Promise { const rows = await db.all( `SELECT key, label, done, done_on, sort, payment_id, document_id FROM project_milestone WHERE client_module_id=? ORDER BY sort, label`, clientModuleId, ) return rows.map((r) => ({ key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort, paymentId: r.payment_id, documentId: r.document_id, })) } /** * Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears * it (and any linked payment / document). A payment-step tick (`payment_received`) can pass * `paymentId` to link a real payment — it must belong to the same client as this project, * and its `received_on` mirrors into `done_on` unless an explicit `doneOn` is also given. * A quotation-step tick (`quotation_sent`) can likewise pass `documentId` to link the * quotation / proforma that was sent — it must belong to the same client too. Audited. */ export async function setMilestone( db: DB, userId: string, clientModuleId: string, key: string, done: boolean, doneOn?: string, paymentId?: string, documentId?: string, ): Promise { return db.transaction(async () => { const before = await db.get( `SELECT key, label, done, done_on, sort, payment_id, document_id FROM project_milestone WHERE client_module_id=? AND key=?`, clientModuleId, key, ) if (before === undefined) throw new Error('Milestone not found on this project') if (done && doneOn !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(doneOn)) { throw new Error('doneOn must be YYYY-MM-DD') } let stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null let linkedPayment: string | null = null if (done && paymentId !== undefined && paymentId !== '') { const pay = await db.get<{ received_on: string }>( `SELECT received_on FROM payment WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`, paymentId, clientModuleId, ) if (pay === undefined) throw new Error('Payment not found for this client') linkedPayment = paymentId if (doneOn === undefined) stamp = pay.received_on // link date wins unless caller forced one } let linkedDocument: string | null = null if (done && documentId !== undefined && documentId !== '') { const doc = await db.get<{ id: string }>( `SELECT id FROM document WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`, documentId, clientModuleId, ) if (doc === undefined) throw new Error('Document not found for this client') linkedDocument = documentId } await db.run( `UPDATE project_milestone SET done=?, done_on=?, payment_id=?, document_id=? WHERE client_module_id=? AND key=?`, done ? 1 : 0, stamp, done ? linkedPayment : null, done ? linkedDocument : null, clientModuleId, key, ) await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, { key, done, doneOn: stamp, paymentId: linkedPayment, documentId: linkedDocument }) if (done) await advanceStatusForStep(db, userId, clientModuleId, key, stamp) return listProjectMilestones(db, clientModuleId) }) } /** Add a project-specific milestone (beyond the standard template). Audited. */ export async function addProjectMilestone( db: DB, userId: string, clientModuleId: string, label: string, ): Promise { return db.transaction(async () => { if (label.trim() === '') throw new Error('Milestone label is required') const key = `custom_${uuidv7().slice(0, 8)}` const maxSort = (await db.get<{ m: number }>( `SELECT COALESCE(MAX(sort), -1) AS m FROM project_milestone WHERE client_module_id=?`, clientModuleId, ))!.m await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) VALUES (?, ?, ?, ?, 0, NULL, ?)`, uuidv7(), clientModuleId, key, label.trim(), maxSort + 1, ) await writeAudit(db, userId, 'add_milestone', 'client_module', clientModuleId, undefined, { key, label: label.trim() }) return listProjectMilestones(db, clientModuleId) }) } /** * Bulk-tick one milestone across many projects at once (D26) — for onboarding cleanup * (e.g. mark the already-live services 'installed' + 'go-live'). One transaction; a * single summary audit row. Only touches projects that actually have the milestone. * * FIX F: routes each project through `setMilestone` — the SAME status-advance and * payment/document link-clearing logic a single tick uses — instead of a bare UPDATE. * Before this, a bulk `go_live` left `client_module.status` at 'quoted' (never advanced) * and a bulk untick left a stale `payment_id`/`document_id` link on the row. */ export async function bulkSetMilestone( db: DB, userId: string, clientModuleIds: string[], key: string, done: boolean, doneOn?: string, ): Promise<{ updated: number }> { if (clientModuleIds.length === 0) return { updated: 0 } if (done && doneOn !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(doneOn)) { throw new Error('doneOn must be YYYY-MM-DD') } const stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null return db.transaction(async () => { let updated = 0 for (const id of clientModuleIds) { try { await setMilestone(db, userId, id, key, done, doneOn) updated++ } catch (err) { // Bulk ops only ever touch projects that actually carry this milestone key — same // as before this fix, just expressed via setMilestone's own "not found" error // instead of a bare UPDATE quietly affecting 0 rows. Anything else (a genuine, // unexpected failure) must still surface, not be swallowed. if (err instanceof Error && err.message === 'Milestone not found on this project') continue throw err } } await writeAudit(db, userId, 'bulk_set_milestone', 'project_milestone', '*', undefined, { key, done, doneOn: stamp, projects: clientModuleIds.length, updated }) return { updated } }) } // ---------- cross-project reporting (the board) ---------- export interface MilestoneBoardRow { key: string; label: string; total: number; done: number; pending: number } /** * Per-milestone completion across ALL active projects — the onboarding board. Ordered by * the standard template; ad-hoc custom milestones are aggregated too, after the standard set. */ export async function milestoneBoard(db: DB): Promise { const rows = await db.all<{ key: string; label: string; total: number; done: number; minsort: number }>( `SELECT pm.key, MIN(pm.label) AS label, COUNT(*) AS total, COALESCE(SUM(pm.done), 0) AS done, MIN(pm.sort) AS minsort FROM project_milestone pm JOIN client_module cm ON cm.id = pm.client_module_id WHERE cm.active = 1 GROUP BY pm.key ORDER BY minsort, label`, ) return rows.map((r) => ({ key: r.key, label: r.label, total: r.total, done: r.done, pending: r.total - r.done })) } export interface PendingProject { clientModuleId: string; clientId: string; clientName: string; moduleCode: string; moduleName: string } /** Active projects where the given milestone is NOT yet done — the drill-down. */ export async function projectsPendingMilestone(db: DB, key: string): Promise { return db.all( `SELECT pm.client_module_id AS "clientModuleId", cm.client_id AS "clientId", c.name AS "clientName", m.code AS "moduleCode", m.name AS "moduleName" FROM project_milestone pm JOIN client_module cm ON cm.id = pm.client_module_id JOIN client c ON c.id = cm.client_id JOIN module m ON m.id = cm.module_id WHERE cm.active = 1 AND pm.key = ? AND pm.done = 0 ORDER BY c.name`, key, ) } export interface StalledProjectsResult { rows: PendingProject[] /** FIX 4 (no silent caps): projects that have a pending step but whose age can't be proven * (no ticks AND no client_module.created_at — e.g. a pre-created_at row, or one an import * didn't stamp) are excluded from `rows` rather than guessed at. This count says how many * were dropped for that reason, so the caller can surface "N of unknown age" instead of * silently under-reporting the stalled list. */ unknownAgeExcluded: number } /** * Active projects with at least one pending step whose progress last moved more than * `olderThanDays` ago (measured against `today`, passed in — never `new Date()` here, so this * stays testable). "Last moved" is the most recent `done_on` across the project's ticked * steps; a project with nothing ticked yet is measured from `client_module.created_at` * instead (FIX D — treating a NULL last-moved date as an infinitely old one made every * freshly-assigned project show up as stalled immediately). When even that isn't known (a * project created before the created_at column existed, or carried over by an import that * doesn't stamp it) there is no way to prove it's actually old, so it is excluded rather than * guessed at — the intent is "parked past N days", not "we don't know, so assume forever" — * and counted in `unknownAgeExcluded` (FIX 4) so that exclusion is never silent. * Non-aggregated columns are wrapped in MIN() — grouping by `pm.client_module_id` alone isn't * enough for Postgres to infer they're single-valued per group (D15 portability). */ export async function stalledProjects( db: DB, olderThanDays: number, today: string, ): Promise { const allRows = await db.all( `SELECT pm.client_module_id AS "clientModuleId", MIN(cm.client_id) AS "clientId", MIN(c.name) AS "clientName", MIN(m.code) AS "moduleCode", MIN(m.name) AS "moduleName", MAX(pm.done_on) AS last_done, MIN(cm.created_at) AS created_at FROM project_milestone pm JOIN client_module cm ON cm.id = pm.client_module_id JOIN client c ON c.id = cm.client_id JOIN module m ON m.id = cm.module_id WHERE cm.active = 1 AND cm.status NOT IN ('live','expired','cancelled') GROUP BY pm.client_module_id HAVING SUM(CASE WHEN pm.done = 0 THEN 1 ELSE 0 END) > 0`, ) const cutoff = new Date(Date.parse(today) - olderThanDays * 86_400_000).toISOString().slice(0, 10) const rows: PendingProject[] = [] let unknownAgeExcluded = 0 for (const r of allRows) { const { last_done, created_at, ...rest } = r if (last_done !== null) { if (last_done < cutoff) rows.push(rest) continue } if (created_at !== null) { if (created_at.slice(0, 10) < cutoff) rows.push(rest) continue } unknownAgeExcluded++ } return { rows, unknownAgeExcluded } }