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.
473 lines
23 KiB
TypeScript
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 }
|
|
}
|