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/repos-milestones.ts

473 lines
23 KiB
TypeScript

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:<CODE>` 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<ordered<installing<installed<trained<live<expired<cancelled. */
const STATUS_RANK: Record<string, number> = {
quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7,
}
const STEP_STATUS: Record<string, string> = {
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<void> {
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<string, unknown> = { status: cm.status }
const after: Record<string, unknown> = { 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<TemplateEntry[]> {
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<TemplateEntry[]> {
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<TemplateEntry[]> {
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<string>()
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<string[]> {
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<string>, 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<TemplateEntry[]> {
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<TemplateEntry[]> {
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<void> {
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<Milestone[]> {
const rows = await db.all<MilestoneRow>(
`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<Milestone[]> {
return db.transaction(async () => {
const before = await db.get<MilestoneRow>(
`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<Milestone[]> {
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<MilestoneBoardRow[]> {
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<PendingProject[]> {
return db.all<PendingProject>(
`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<StalledProjectsResult> {
const allRows = await db.all<PendingProject & { last_done: string | null; created_at: string | null }>(
`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 }
}