feat(onboarding): stalled-project query for the dashboard/MIS tile

Adds stalledProjects(db, olderThanDays, today) to repos-milestones.ts:
active projects with a pending step whose last progress (MAX done_on,
or module creation if nothing ticked) is older than N days. GET
/projects/stalled reads ?days= with a project.stall_days setting
fallback (default 30).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent 195d821bef
commit 75fb41c366

@ -70,7 +70,7 @@ import { assertSafeGatewayUrl } from './sms-gateway'
import { makeRateLimiter } from './rate-limit'
import {
addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard,
projectsPendingMilestone, setMilestone,
projectsPendingMilestone, setMilestone, stalledProjects,
} from './repos-milestones'
import { commitImport, stageCsv, verificationReport } from './import-apex'
import { documentHtml, documentHtmlSample } from './templates'
@ -755,6 +755,14 @@ export function apiRouter(
r.get('/projects/pending/:key', requireAuth, async (req, res) => {
res.json({ ok: true, projects: await projectsPendingMilestone(db, String(req.params['key'] ?? '')) })
})
// Parked onboarding: active projects whose next step hasn't moved in a while (dashboard/MIS tile).
r.get('/projects/stalled', requireAuth, async (req, res) => {
const q = req.query['days']
const days = typeof q === 'string' && q !== '' && Number.isFinite(Number(q))
? Number(q) : await getNumberSetting(db, 'project.stall_days', 30)
const today = new Date().toISOString().slice(0, 10)
res.json({ ok: true, projects: await stalledProjects(db, days, today) })
})
// ---------- client branches (D20) ----------
r.get('/clients/:id/branches', requireAuth, async (req, res) => {

@ -236,3 +236,30 @@ export async function projectsPendingMilestone(db: DB, key: string): Promise<Pen
key,
)
}
/**
* 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 counts as parked since creation (no lower bound).
* 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<PendingProject[]> {
const rows = await db.all<PendingProject & { last_done: 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
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)
return rows
.filter((r) => (r.last_done ?? '0000-00-00') < cutoff)
.map(({ last_done: _last_done, ...rest }) => rest)
}

@ -0,0 +1,57 @@
// apps/hq/test/stalled-onboarding.test.ts — Task 8: stalled-onboarding query (dashboard/MIS tile).
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule } from '../src/repos-modules'
import { setMilestone, stalledProjects } from '../src/repos-milestones'
async function world() {
const db = openDb(':memory:')
await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
// A module code with no per-module milestone template seeded (D22 seed.ts only carries
// SMS/RTGS/MOBILEAPP overrides), so the global template applies: advance_paid, setup_done,
// installed, go_live, balance_paid, amc_start.
const m = await createModule(db, 'u1', { code: 'MISC', name: 'Misc Module' })
return { db, c, m }
}
describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
it('lists a project parked past the threshold', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
// Early steps done on an old date; later steps (incl. go_live) stay pending — project is
// still active (status stays 'quoted'/'installed', never 'live').
await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01')
await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01')
// last done_on = 2026-03-01, today = 2026-07-19 → ~140 days parked.
const stalled = await stalledProjects(db, 30, '2026-07-19')
expect(stalled.map((p) => p.clientModuleId)).toContain(cm.id)
const row = stalled.find((p) => p.clientModuleId === cm.id)!
expect(row.clientName).toBe('Kothavara SCB')
expect(row.moduleCode).toBe('MISC')
})
it('excludes a recently-progressed project', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01')
await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01')
const stalled = await stalledProjects(db, 365, '2026-07-19')
expect(stalled).toHaveLength(0)
})
it('excludes projects with no pending steps (fully ticked) and inactive/live projects', async () => {
const { db, c, m } = await world()
// Fully ticked project: nothing pending, so it should never appear regardless of age.
const done = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
for (const key of ['advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start']) {
await setMilestone(db, 'u1', done.id, key, true, '2026-01-01')
}
const stalled = await stalledProjects(db, 1, '2026-07-19')
expect(stalled.map((p) => p.clientModuleId)).not.toContain(done.id)
// 'live' status (from ticking go_live) also excludes it, which the loop above proves.
expect((await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, done.id))!.status).toBe('live')
})
})
Loading…
Cancel
Save