Compare commits

..

No commits in common. '551c9e3d30adbf0957bfc1927affb46877f6fe38' and 'b1e1420b4ccc7251144c6f561aaa342a571ebb33' have entirely different histories.

@ -1,179 +0,0 @@
{
"running": true,
"startedAt": "2026-07-20T16:44:54.772Z",
"workers": {
"map": {
"runCount": 11,
"successCount": 11,
"failureCount": 0,
"averageDurationMs": 8.090909090909092,
"isRunning": false,
"nextRun": "2026-07-20T19:29:55.184Z",
"lastStartedAt": "2026-07-20T19:14:55.176Z",
"lastRun": "2026-07-20T19:14:55.183Z"
},
"audit": {
"runCount": 17,
"successCount": 17,
"failureCount": 0,
"averageDurationMs": 8.882352941176471,
"isRunning": false,
"nextRun": "2026-07-20T19:26:56.025Z",
"lastStartedAt": "2026-07-20T19:26:56.097Z",
"lastRun": "2026-07-20T19:26:56.134Z"
},
"optimize": {
"runCount": 11,
"successCount": 11,
"failureCount": 0,
"averageDurationMs": 6.454545454545454,
"isRunning": false,
"nextRun": "2026-07-20T19:33:55.170Z",
"lastStartedAt": "2026-07-20T19:18:55.161Z",
"lastRun": "2026-07-20T19:18:55.166Z"
},
"consolidate": {
"runCount": 6,
"successCount": 6,
"failureCount": 0,
"averageDurationMs": 7.5,
"isRunning": false,
"nextRun": "2026-07-20T19:50:55.096Z",
"lastStartedAt": "2026-07-20T19:20:55.092Z",
"lastRun": "2026-07-20T19:20:55.094Z"
},
"testgaps": {
"runCount": 8,
"successCount": 8,
"failureCount": 0,
"averageDurationMs": 8.375,
"isRunning": false,
"nextRun": "2026-07-20T19:32:55.117Z",
"lastStartedAt": "2026-07-20T19:12:55.104Z",
"lastRun": "2026-07-20T19:12:55.114Z"
},
"backup": {
"runCount": 1,
"successCount": 1,
"failureCount": 0,
"averageDurationMs": 9,
"isRunning": false,
"nextRun": "2026-07-21T16:54:54.808Z",
"lastStartedAt": "2026-07-20T16:54:54.797Z",
"lastRun": "2026-07-20T16:54:54.806Z"
},
"harness": {
"runCount": 1,
"successCount": 1,
"failureCount": 0,
"averageDurationMs": 729,
"isRunning": false,
"nextRun": "2026-07-20T22:56:55.533Z",
"lastStartedAt": "2026-07-20T16:56:54.797Z",
"lastRun": "2026-07-20T16:56:55.526Z"
},
"predict": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false
},
"document": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false
}
},
"config": {
"autoStart": false,
"logDir": "C:\\SiMS\\hq\\.claude-flow\\logs",
"stateFile": "C:\\SiMS\\hq\\.claude-flow\\daemon-state.json",
"maxConcurrent": 2,
"workerTimeoutMs": 960000,
"resourceThresholds": {
"maxCpuLoad": 9.600000000000001,
"minFreeMemoryPercent": 10
},
"ttlMs": 43200000,
"idleShutdownMs": 0,
"aiWorkersEnabled": false,
"workers": [
{
"type": "map",
"intervalMs": 900000,
"offsetMs": 0,
"priority": "normal",
"description": "Codebase mapping",
"enabled": true
},
{
"type": "audit",
"intervalMs": 600000,
"offsetMs": 120000,
"priority": "critical",
"description": "Security analysis",
"enabled": true
},
{
"type": "optimize",
"intervalMs": 900000,
"offsetMs": 240000,
"priority": "high",
"description": "Performance optimization",
"enabled": true
},
{
"type": "consolidate",
"intervalMs": 1800000,
"offsetMs": 360000,
"priority": "low",
"description": "Memory distillation (ADR-174)",
"enabled": true
},
{
"type": "testgaps",
"intervalMs": 1200000,
"offsetMs": 480000,
"priority": "normal",
"description": "Test coverage analysis",
"enabled": true
},
{
"type": "backup",
"intervalMs": 86400000,
"offsetMs": 600000,
"priority": "low",
"description": "Nightly memory DB backup (WAL-safe, rotated)",
"enabled": true
},
{
"type": "harness",
"intervalMs": 21600000,
"offsetMs": 720000,
"priority": "low",
"description": "Self-optimizing harness loop (opt-in RUFLO_HARNESS_LOOP, $0-default)",
"enabled": true
},
{
"type": "predict",
"intervalMs": 600000,
"offsetMs": 0,
"priority": "low",
"description": "Predictive preloading",
"enabled": false
},
{
"type": "document",
"intervalMs": 3600000,
"offsetMs": 0,
"priority": "low",
"description": "Auto-documentation",
"enabled": false
}
]
},
"savedAt": "2026-07-20T19:26:56.135Z"
}

@ -1,11 +0,0 @@
{
"timestamp": "2026-07-20T19:14:55.182Z",
"projectRoot": "C:\\SiMS\\hq",
"structure": {
"hasPackageJson": true,
"hasTsConfig": true,
"hasClaudeConfig": true,
"hasClaudeFlow": true
},
"scannedAt": 1784574895183
}

@ -1,16 +0,0 @@
{
"timestamp": "2026-07-20T19:20:55.094Z",
"distillationEnabled": true,
"patternsConsolidated": 0,
"memoryCleaned": 0,
"duplicatesRemoved": 0,
"episodes": 0,
"patternEmbeddings": 0,
"causalEdges": 0,
"promoted": 0,
"byProvenance": {},
"namespaces": [],
"dryRun": false,
"corrupt": false,
"skipped": "no-db"
}

@ -1,17 +0,0 @@
{
"timestamp": "2026-07-20T19:18:55.166Z",
"mode": "local",
"memoryUsage": {
"rss": 46977024,
"heapTotal": 21909504,
"heapUsed": 20452192,
"external": 3903735,
"arrayBuffers": 100670
},
"uptime": 9240.595012,
"optimizations": {
"cacheHitRate": 0.78,
"avgResponseTime": 45
},
"note": "Install Claude Code CLI for AI-powered optimization suggestions"
}

@ -1,12 +0,0 @@
{
"timestamp": "2026-07-20T19:26:56.127Z",
"mode": "local",
"checks": {
"envFilesProtected": true,
"gitIgnoreExists": true,
"noHardcodedSecrets": true
},
"riskLevel": "low",
"recommendations": [],
"note": "Install Claude Code CLI for AI-powered security analysis"
}

@ -1,8 +0,0 @@
{
"timestamp": "2026-07-20T19:12:55.112Z",
"mode": "local",
"hasTestDir": false,
"estimatedCoverage": "unknown",
"gaps": [],
"note": "Install Claude Code CLI for AI-powered test gap analysis"
}

@ -1 +0,0 @@
sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319

@ -1,42 +0,0 @@
{
"adoptedAt": 1784565886864,
"championId": "sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319",
"manifest": {
"schema": "ruflo.proven-config/v1",
"policy": {
"ref": "sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319",
"value": {
"alpha": 0.3,
"subjectWeight": 1,
"mmrLambda": 0.5,
"bodyWeight": 1.5,
"typePenaltyFactor": 0.5
}
},
"layer": "framework/node-cli",
"compatibility": {
"ruflo": ">=3.24.0"
},
"benchmark": {
"corpus": "ADR-081-labelled-v1",
"corpusHash": "sha256:2f700b5c363e20a3bd88ce2bc9b87bbbbaa61732c6177894c6ec37890f888982"
},
"receipt": {
"heldOutDelta": 0.07381404928570845,
"redblue": "PASS",
"drift": 0,
"canary": {
"rollbackRate": 0,
"latencyP95": 244.612458000076,
"costPerTask": 0
},
"receiptCoverage": 1
},
"platform": [
"linux",
"macOS",
"windows"
]
},
"previous": ""
}

@ -70,7 +70,7 @@ header stacks instead of overlapping the title).
| `npm run typecheck` (root + workspaces) | clean, no errors |
| `npm test` (`vitest run`) | **528 tests pass across 97 files** (5 Postgres-gated skipped) |
The 528 figure is the live `vitest run` count measured 2026-07-19; the suite has
The 416 figure is the live `vitest run` count measured 2026-07-19; the suite has
grown from the D18 go-live cluster through the D20–D33 work (tickets, module fields,
renewals, cockpit, audit viewer, SMS gateway, module directory/portals/search, and
the D31/D32 plaintext-credential change with its own migration + review). Test files

@ -275,10 +275,9 @@ button.dash-row:hover { background: var(--accent-soft); }
own horizontal-scroll container from `overflow-x: auto` in Chromium, so at desktop
widths a wide table (e.g. Tickets' 11 columns) was overflowing past the viewport and
getting silently clipped by `.hq-content`'s overflow-x: hidden below, unreachable at
any width. The wrapper scrolls at every width now, not just <901px.
(`.wf-table-wrap` itself is styled in @sims/ui packages/ui/src/tokens.css next to
the other table.wf rules, since it's part of that component, not this app's shell.) */
any width. The wrapper scrolls at every width now, not just <901px. */
.hq-content { overflow-x: hidden; }
.wf-table-wrap { overflow-x: auto; }
/* Relationship-pulse ribbon (Client 360 overview). */
.pulse {
@ -517,15 +516,11 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse
.cdr-node.done .nm { color: var(--text-dim); }
.cdr-node.now .nm { font-weight: 600; }
.cdr-node .dt { font-size: 10px; color: var(--text-dim); font-family: var(--mono); margin-top: 3px; }
/* Linked payment/document reference under a done step's date. It WRAPS (up to two short
lines) instead of being ellipsised to one 104px line: the reference exists to show the
payment's amount / the document's number, and a single mono line at this width cut
exactly that off. Wrapping grows the node downwards only, so the track keeps its 112px
columns and its horizontal scroll on narrow screens. */
/* linked payment/document reference under a done step's date */
.cdr-node .ref {
font-size: 10px; font-family: var(--mono); color: var(--accent); background: none; border: none;
padding: 0; margin-top: 3px; cursor: pointer; max-width: 104px;
white-space: normal; overflow-wrap: anywhere; line-height: 1.3; text-align: center;
padding: 0; margin-top: 3px; cursor: pointer; max-width: 104px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.cdr-node .ref:hover { text-decoration: underline; }
/* outstanding panel */

@ -52,9 +52,6 @@ export function ClientDetail() {
const id = useParams()['id'] ?? ''
const nav = useNavigate()
const [sp, setSp] = useSearchParams()
const rawTab = sp.get('tab') ?? 'overview'
const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview'
const setTab = (k: string) => setSp(k === 'overview' ? {} : { tab: k }, { replace: true })
const client = useData(() => getClient(id), [id])
const modules = useData(getModules, [])
const cms = useData(() => getClientModules(id), [id])
@ -69,9 +66,7 @@ export function ClientDetail() {
const [ticketPage, setTicketPage] = useState(1)
const tickets = useData(() => getTickets({ clientId: id, page: ticketPage, pageSize: 50 }), [id, ticketPage])
// Same classification list the Tickets desk uses, so the Type column reads identically here.
// Only needed for that column, so only fetch it once the tickets tab is actually open —
// NewTicketDialog fetches its own copy for the new-ticket type picker, independent of this.
const ticketTypes = useData(() => (tab === 'tickets' ? getTicketTypes() : Promise.resolve(TICKET_TYPE_FALLBACK)), [tab === 'tickets'])
const ticketTypes = useData(getTicketTypes, [])
const ticketTypeOptions = ticketTypes.data ?? TICKET_TYPE_FALLBACK
// Undefined = closed. `moduleCode` pre-fills the module when the ticket is raised from
// a module block ("Raise ticket for this module") rather than the Tickets tab button.
@ -104,6 +99,10 @@ export function ClientDetail() {
const [linkingDoc, setLinkingDoc] = useState(false)
const [docLinkError, setDocLinkError] = useState<string | undefined>()
const rawTab = sp.get('tab') ?? 'overview'
const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview'
const setTab = (k: string) => setSp(k === 'overview' ? {} : { tab: k }, { replace: true })
const c = client.data
if (client.error !== undefined) return <ErrorState message={client.error} onRetry={client.reload} />
if (c === undefined) return <div className="wf-page"><Skeleton rows={6} /></div>
@ -346,7 +345,6 @@ export function ClientDetail() {
name={moduleName(cm.moduleId)}
refreshToken={milestonesVersion}
payments={ledger.data?.payments ?? []}
docs={ledger.data?.documents ?? []}
onTickPayment={(key, label) => { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }}
onTickCreds={(key, label) => { setCredsError(undefined); setCredsDialog({ cm, key, label }) }}
onTickDocument={(key, label) => { setDocLinkError(undefined); setDocPicker({ clientModuleId: cm.id, key, label }) }}
@ -1429,10 +1427,7 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
* Un-ticking any of them toggles directly (the server clears the link).
* A done step carrying `paymentId`/`documentId` also renders a small reference under its
* date (`onOpenDocument` / `onOpenPayments`) otherwise a linked step looks identical to a
* bare-toggled one. That reference is resolved against the client's ledger (`payments` /
* `docs`) so it reads as the real date + amount / document number, wraps to a second line
* inside the node rather than being ellipsised, and carries the full detail in its
* tooltip + accessible name.
* bare-toggled one.
* "+ Add step" appends a project-specific milestone. Every change is one audited POST
* that returns the fresh list; `refreshToken` lets the parent force a refetch after a
* link lands from outside this component. `onChanged` additionally reloads the parent's
@ -1442,10 +1437,7 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
*/
function StatusLine(props: {
clientModuleId: string; name: string; refreshToken: number
/** The client's ledger — used to render the linked payment / document by its real
* date + amount (and to fill the reference's tooltip), not a generic "linked" label. */
payments: Payment[]
docs: Doc[]
onTickPayment: (key: string, label: string) => void
onTickCreds: (key: string, label: string) => void
onTickDocument: (key: string, label: string) => void
@ -1555,33 +1547,20 @@ function StatusLine(props: {
<span className="nm">{m.label}</span>
<span className="dt">{m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}</span>
{/* The linked payment/document itself otherwise a done payment/quotation
step shows only a date, with no evidence the link actually landed. The
reference wraps inside the node (it used to be ellipsised to one 104px
line, which cut the amount the one thing it exists to show), and the
tooltip/accessible name carries the full detail rather than static text. */}
{m.done && m.documentId !== null && (() => {
const d = props.docs.find((x) => x.id === m.documentId)
const detail = d === undefined
? 'Linked document — open it'
: `${docLabel(d)} ${d.docNo ?? '(draft)'} · ${d.docDate} · ${inr(d.payablePaise)} — open it`
return (
<button
type="button" className="ref" title={detail} aria-label={detail}
onClick={() => props.onOpenDocument(m.documentId!)}
>
{d === undefined ? 'view document' : d.docNo ?? `${docLabel(d)} (draft)`}
</button>
)
})()}
step shows only a date, with no evidence the link actually landed. */}
{m.done && m.documentId !== null && (
<button
type="button" className="ref" title="Open the linked document"
onClick={() => props.onOpenDocument(m.documentId!)}
>
view document
</button>
)}
{m.done && m.paymentId !== null && (() => {
const p = props.payments.find((x) => x.id === m.paymentId)
const detail = p === undefined
? 'Linked payment — open Payments & plans'
: `Payment ${p.receivedOn} · ${inr(p.amountPaise)}`
+ `${p.reference !== '' ? ` · ${p.reference}` : ''} — open Payments & plans`
return (
<button
type="button" className="ref" title={detail} aria-label={detail}
type="button" className="ref" title="The linked payment — open Payments & plans"
onClick={() => props.onOpenPayments()}
>
{p !== undefined ? `${p.receivedOn} · ${inr(p.amountPaise)}` : 'payment linked'}

@ -70,27 +70,13 @@ export function Dashboard() {
</div>
</section>
{/* SMS low-balance alert (D28) surfaced here so it's seen without opening the SMS
screen. Three states, never collapsed into one: while the check is in flight a
skeleton stands in; a FAILED check says so with a retry (rendering nothing there
would read as "no client is low" the dangerous direction); and only a loaded,
genuinely-empty result gets the positive line. `unknownCount` is spelled out in
that line because balances the gateway couldn't return aren't "fine" either. */}
{smsLow.error !== undefined ? (
<ErrorState message={`SMS balances could not be checked — ${smsLow.error}`} onRetry={smsLow.reload} />
) : smsLow.data === undefined ? (
<Skeleton rows={1} />
) : smsLow.data.lowCount > 0 ? (
{/* SMS low-balance alert (D28) — surfaced here so it's seen without opening the SMS screen. */}
{smsLow.data !== undefined && smsLow.data.lowCount > 0 && (
<Notice tone="warn">
<Link to="/sms-balances" style={{ color: 'inherit', fontWeight: 600 }}>
{smsLow.data.lowCount} SMS client{smsLow.data.lowCount === 1 ? '' : 's'} low on balance review &amp; top up
</Link>
</Notice>
) : (
<p className="dash-cap">
SMS balances checked none low
{smsLow.data.unknownCount > 0 ? ` (${smsLow.data.unknownCount} could not be read)` : ''}.
</p>
)}
{/* KPIs — the app's existing stat cards (kept minimal; red only when a send failed). */}
@ -160,15 +146,11 @@ export function Dashboard() {
</Card>
<Card
title="Onboarding stalled" count={stalled.isLoading ? undefined : (stalled.data?.length ?? 0)}
title="Onboarding stalled" count={stalled.data?.length ?? 0}
empty="Nothing stalled."
// Still fetching (data undefined, no error yet) is not the same as a genuine
// empty result — show the usual loading placeholder, not "Nothing stalled.",
// until the request actually resolves one way or the other.
loading={stalled.isLoading}
// A fetch failure is likewise not the same as empty — show the error (with
// retry) instead of quietly reporting "Nothing stalled." on failure.
isEmpty={!stalled.isLoading && stalled.error === undefined && (stalled.data ?? []).length === 0}
// A fetch failure is not the same as a genuine empty result — show the error
// (with retry) instead of quietly reporting "Nothing stalled." on failure.
isEmpty={stalled.error === undefined && (stalled.data ?? []).length === 0}
>
{stalled.error !== undefined
? <ErrorState message={stalled.error} onRetry={stalled.reload} />
@ -200,12 +182,10 @@ export function Dashboard() {
}
/** One bento card: an uppercase-labelled header (+ count and optional link) over a
* scrollable list, or an empty-state line when there's nothing to show. Cards fed by
* their own request pass `loading` so a fetch still in flight shows a skeleton instead
* of the empty line "nothing to show" must mean the data loaded and was empty. */
* scrollable list, or an empty-state line when there's nothing to show. */
function Card(props: {
title: string; count?: number; link?: { to: string; label?: string }
empty: string; isEmpty: boolean; loading?: boolean; children: ReactNode
empty: string; isEmpty: boolean; children: ReactNode
}) {
return (
<section className="dash-card">
@ -214,9 +194,7 @@ function Card(props: {
{props.count !== undefined && <span className="count">{props.count}</span>}
{props.link !== undefined && <Link to={props.link.to}>{props.link.label ?? 'All'} </Link>}
</div>
{props.loading === true
? <div className="dash-list"><Skeleton rows={2} /></div>
: props.isEmpty ? <div className="dash-empty">{props.empty}</div> : <div className="dash-list">{props.children}</div>}
{props.isEmpty ? <div className="dash-empty">{props.empty}</div> : <div className="dash-list">{props.children}</div>}
</section>
)
}

@ -4,8 +4,6 @@
// skips a client whose name already contains that number, and only touches found=true rows.
// Does NOT overwrite the name's spelling/casing — only appends the number.
// LTD_JSON=<results.json> DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-ltd-numbers.ts
// SQLite is never the default: to target a local SQLite database, opt in explicitly with
// HQ_DB_TARGET=sqlite (or the --sqlite flag). Without either, the script refuses to run.
import { readFileSync } from 'node:fs'
import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'

@ -1,8 +1,6 @@
// apps/hq/scripts/apply-name-fixes.ts — one-off (D35 data correction): apply the approved
// client-name corrections from name-review.json via the audited updateClient path.
// REVIEW_JSON=<path> DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-name-fixes.ts
// SQLite is never the default: to target a local SQLite database, opt in explicitly with
// HQ_DB_TARGET=sqlite (or the --sqlite flag). Without either, the script refuses to run.
import { readFileSync } from 'node:fs'
import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'

@ -5,12 +5,8 @@
// is written only if it is still ABSENT, so a plain re-run never clobbers an owner's edit made
// in the Modules onboarding-template editor. Pass --force to unconditionally overwrite every
// template with the settings below — this DOES clobber owner edits, use with care.
// Run from repo root, naming the database explicitly (this header previously omitted the
// DATABASE_URL prefix — followed literally on the production host it resolves to SQLite, not
// the real database, and the script now refuses rather than migrating the wrong DB):
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts [--force]
// To deliberately run against a local SQLite database instead, opt in explicitly:
// HQ_DB_TARGET=sqlite npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts (or --sqlite)
// Run from repo root:
// npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts [--force]
import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'

@ -1,24 +0,0 @@
// apps/hq/scripts/backup.ts — `npm run backup` (D33). Dumps the production Postgres to a
// timestamped, compressed, restorable file under ./backups (or $HQ_BACKUP_DIR) and rotates to
// the newest $HQ_BACKUP_KEEP (default 14). Run from the repo root:
// npm run backup
// DATABASE_URL=postgres://… npm run backup (explicit target, overrides resolution)
// The target is resolved through the SAME shared resolver the server and every other
// maintenance script uses (`requireDbUrlForEnv`, D19) — the old scripts/backup.mjs read
// DATABASE_URL directly and so could never find the production database, which is resolved
// rather than exported on that box. The resolved host/db is logged (never the password)
// before pg_dump runs.
//
// Restore a dump with:
// pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" backups/hq-<ts>.dump
//
// SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext
// logins. Keep backups/ access-controlled and off any shared drive (it is gitignored).
import { runBackup } from '../src/backup'
try {
runBackup()
} catch (e) {
console.error(e instanceof Error ? e.message : String(e))
process.exit(1)
}

@ -3,8 +3,6 @@
// and must_change_password=1 (they change it on first login). Active per ACTIVE_STAT.
// Idempotent: skips a username/email that already exists.
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/import-employees.ts
// SQLite is never the default: to target a local SQLite database, opt in explicitly with
// HQ_DB_TARGET=sqlite (or the --sqlite flag). Without either, the script refuses to run.
import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { createEmployee } from '../src/repos-employees'

@ -3,8 +3,6 @@
// checklist onto their new per-module template, carrying mapped ticks. Idempotent — safe
// to re-run (a second run does nothing).
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/migrate-onboarding-templates.ts
// SQLite is never the default: to target a local SQLite database, opt in explicitly with
// HQ_DB_TARGET=sqlite (or the --sqlite flag). Without either, the script refuses to run.
import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'

@ -1,75 +0,0 @@
// Go-live data correction: the earlier migrate-sqlite-to-pg.ts run pulled from local SQLite,
// which turned out to be stale/near-empty — the real working data was in a local Postgres
// instance instead. This wipes the TARGET (cloud) hq database's public schema and reloads it
// from the real SOURCE (local Postgres). Both engines are Postgres here, so every column type
// matches exactly — no conversion needed.
// SOURCE_DATABASE_URL and TARGET_DATABASE_URL must both be set explicitly, no defaults, so
// this can never accidentally point at (or wipe) the wrong database.
//
// Run from repo root:
// SOURCE_DATABASE_URL=postgres://hq:<local-pw>@127.0.0.1:5432/hq ^
// TARGET_DATABASE_URL=postgres://postgres:<cloud-pw>@13.232.53.115:5432/hq ^
// npx tsx apps/hq/scripts/migrate-pg-to-pg.ts
import { Pool } from 'pg'
import { openPgDb, type PgDb } from '../src/db-pg'
const SKIP_TABLES = new Set(['stg_client', 'stg_invoice', 'schema_migrations'])
async function main(): Promise<void> {
const sourceUrl = process.env['SOURCE_DATABASE_URL']
const targetUrl = process.env['TARGET_DATABASE_URL']
if (sourceUrl === undefined || sourceUrl === '') throw new Error('Set SOURCE_DATABASE_URL (local Postgres).')
if (targetUrl === undefined || targetUrl === '') throw new Error('Set TARGET_DATABASE_URL (cloud Postgres).')
if (sourceUrl === targetUrl) throw new Error('SOURCE and TARGET are identical — refusing.')
// Wipe the target's public schema completely, then let the app's own migrations rebuild
// it clean — guarantees the target schema matches this codebase exactly, not a stale copy.
const wipePool = new Pool({ connectionString: targetUrl, max: 1 })
await wipePool.query('DROP SCHEMA public CASCADE')
await wipePool.query('CREATE SCHEMA public')
await wipePool.end()
console.log('Target schema wiped.')
const target = (await openPgDb(targetUrl)) as PgDb // recreates schema via migrations, empty
const source = (await openPgDb(sourceUrl)) as PgDb // idempotent no-op if already migrated
const tables = await source.all<{ tablename: string }>(`SELECT tablename FROM pg_tables WHERE schemaname='public'`)
const client = await target.pool.connect()
try {
await client.query('BEGIN')
// Load in discovery order and ignore FK/trigger checks — safe: one transaction into a
// target we just wiped and confirmed empty.
await client.query('SET LOCAL session_replication_role = replica')
for (const { tablename: name } of tables) {
if (SKIP_TABLES.has(name)) {
console.log(`skip ${name}`)
continue
}
const rows = await source.all<Record<string, unknown>>(`SELECT * FROM ${name}`)
if (rows.length === 0) {
console.log(`${name}: 0 rows`)
continue
}
const cols = Object.keys(rows[0]!)
const placeholders = cols.map((_, i) => `$${i + 1}`).join(',')
const insertSql = `INSERT INTO ${name} (${cols.join(',')}) VALUES (${placeholders})`
for (const row of rows) {
await client.query(insertSql, cols.map((c) => row[c]))
}
console.log(`${name}: ${rows.length} row(s) migrated`)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
console.log('Migration complete.')
}
main().catch((e: unknown) => {
console.error(e)
process.exit(1)
})

@ -1,76 +0,0 @@
// Go-live data carry-over: copies every row from the local SQLite dev database into the
// target (cloud) Postgres database, preserving values and ids as-is — this is a one-time
// full copy, not the "born in Postgres" empty-start path in docs/DEPLOY-HQ.md.
// Column types are a straight 1:1 map on this schema (money is bigint paise, flags stay
// integer, JSON payloads stay text — see migrations-pg.ts header), so no value conversion
// is needed; every column is copied through unchanged.
// Refuses to run if the target already has staff_user rows, so a re-run (or pointing at
// the wrong DATABASE_URL) can't silently duplicate or clobber data.
// stg_client/stg_invoice are APEX-import scratch tables, not real data — skipped.
//
// Run from repo root, source = local ./data/hq.db (or HQ_DATA_DIR), target = DATABASE_URL:
// DATABASE_URL=postgres://postgres:<pw>@<host>:5432/hq npx tsx apps/hq/scripts/migrate-sqlite-to-pg.ts
import { openDb, type SqliteDb } from '../src/db'
import { openPgDb, type PgDb } from '../src/db-pg'
const SKIP_TABLES = new Set(['stg_client', 'stg_invoice', 'schema_migrations'])
async function main(): Promise<void> {
const pgUrl = process.env['DATABASE_URL']
if (pgUrl === undefined || pgUrl === '') {
throw new Error('Set DATABASE_URL to the target (cloud) Postgres connection string.')
}
const sqlite = openDb(process.env['HQ_DATA_DIR']) as SqliteDb
const pg = (await openPgDb(pgUrl)) as PgDb // schema is created by migrations here; seedIfEmpty is NOT called
const existingStaff = (await pg.get<{ n: number }>(`SELECT COUNT(*) AS n FROM staff_user`))!.n
if (existingStaff > 0) {
throw new Error(
`Target already has ${existingStaff} staff_user row(s) — refusing to import over existing data. ` +
`Wrong DATABASE_URL, or this has already been migrated.`,
)
}
const tables = sqlite.raw
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
.all() as { name: string }[]
const client = await pg.pool.connect()
try {
await client.query('BEGIN')
// Load in table-discovery order and ignore FK/trigger checks — safe here because the
// whole load is one transaction into a database we just confirmed is empty.
await client.query('SET LOCAL session_replication_role = replica')
for (const { name } of tables) {
if (SKIP_TABLES.has(name)) {
console.log(`skip ${name}`)
continue
}
const rows = sqlite.raw.prepare(`SELECT * FROM ${name}`).all() as Record<string, unknown>[]
if (rows.length === 0) {
console.log(`${name}: 0 rows`)
continue
}
const cols = Object.keys(rows[0]!)
const placeholders = cols.map((_, i) => `$${i + 1}`).join(',')
const insertSql = `INSERT INTO ${name} (${cols.join(',')}) VALUES (${placeholders})`
for (const row of rows) {
await client.query(insertSql, cols.map((c) => row[c]))
}
console.log(`${name}: ${rows.length} row(s) migrated`)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
console.log('Migration complete.')
}
main().catch((e: unknown) => {
console.error(e)
process.exit(1)
})

@ -815,11 +815,7 @@ export function apiRouter(
days = await getNumberSetting(db, 'project.stall_days', 30)
}
const today = new Date().toISOString().slice(0, 10)
// FIX 4 (no silent caps): unknownAgeExcluded surfaces the count of pending projects the
// query could not prove old (no ticks and no client_module.created_at) instead of
// silently dropping them off the tile with no trace.
const { rows, unknownAgeExcluded } = await stalledProjects(db, days, today)
res.json({ ok: true, projects: rows, unknownAgeExcluded })
res.json({ ok: true, projects: await stalledProjects(db, days, today) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}

@ -1,128 +0,0 @@
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
import { sqliteFilePath } from './db'
import { pgTargetDescription, resolveDbUrl } from './db-pg'
/**
* Database backup (D33): `pg_dump` of the production Postgres to a timestamped, compressed,
* restorable file under ./backups (or $HQ_BACKUP_DIR), rotated to the newest N.
*
* Lives in src/ (not scripts/) so it is typechecked and unit-testable; `scripts/backup.ts` is
* the thin CLI wrapper `npm run backup` invokes.
*
* The fix this file exists for: the previous `scripts/backup.mjs` read `process.env.DATABASE_URL`
* directly and bailed out when it was unset. On the production box DATABASE_URL is NOT set the
* connection is resolved by `resolveDbUrl` (D19) so the backup command could never reach the
* real database. It now resolves its target through the SAME shared resolver every other
* maintenance script uses, and logs the resolved target (never the password) before it runs.
*
* SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext logins.
* Keep the backups/ directory access-controlled and off any shared drive; it is gitignored so it
* can never be committed.
*/
/** Env knobs, defaulted here so the CLI wrapper stays a one-liner. */
export const DEFAULT_KEEP = 14
/**
* The Postgres URL this backup run must dump via `resolveDbUrl`, the one shared resolver
* (D19: DATABASE_URL, else the production connection). This is the fix: the target is
* RESOLVED exactly as the server resolves it, instead of being read straight off
* `process.env.DATABASE_URL`, which is not set on the production box.
*
* A SQLite outcome is fatal here rather than an opt-in (unlike the migration scripts, which
* can legitimately run against SQLite): `pg_dump` cannot read a SQLite file at all, so the
* only useful answer is a loud error naming the SQLite snapshot script instead.
*/
export function resolveBackupTarget(env: NodeJS.ProcessEnv = process.env): string {
const url = resolveDbUrl(env)
if (url === '') {
throw new Error(
'[backup] refusing to run: no Postgres target resolved, so this would back up nothing. ' +
`The database resolved to SQLite at ${sqliteFilePath(env['HQ_DATA_DIR'])}, and this ` +
'backup dumps Postgres (pg_dump). Set DATABASE_URL=postgres://… to back up the real ' +
'database, or use apps/hq/scripts/backup-hq.sh for a SQLite snapshot.',
)
}
return url
}
/** Locate pg_dump: PATH first, then a standard Windows PostgreSQL install. */
export function findPgDump(): string | null {
try {
execFileSync(process.platform === 'win32' ? 'where' : 'which', ['pg_dump'], { stdio: 'ignore' })
return 'pg_dump'
} catch { /* not on PATH */ }
if (process.platform === 'win32') {
const base = 'C:/Program Files/PostgreSQL'
try {
for (const v of readdirSync(base).sort().reverse()) {
const cand = join(base, v, 'bin', 'pg_dump.exe')
if (existsSync(cand)) return cand
}
} catch { /* no install dir */ }
}
return null
}
/** `hq-YYYYMMDD-HHMMSS.dump` for the given local time. Pure — the rotation regex must match it. */
export function backupFileName(now: Date): string {
const p2 = (n: number): string => String(n).padStart(2, '0')
const ts = `${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}-` +
`${p2(now.getHours())}${p2(now.getMinutes())}${p2(now.getSeconds())}`
return `hq-${ts}.dump`
}
const DUMP_RE = /^hq-\d{8}-\d{6}\.dump$/
/** Which of `files` fall outside the newest `keep` dumps. Pure, so rotation is testable. */
export function dumpsToRotate(files: readonly string[], keep: number): string[] {
const dumps = files.filter((f) => DUMP_RE.test(f)).sort() // name sorts chronologically
return dumps.slice(0, Math.max(0, dumps.length - Math.max(1, keep)))
}
export interface BackupResult { file: string; bytes: number; rotated: string[]; retained: number }
/**
* Run the backup end to end. `log` is injected so the CLI prints and tests stay quiet.
* Env: HQ_BACKUP_DIR (default ./backups), HQ_BACKUP_KEEP (default 14).
*/
export function runBackup(
env: NodeJS.ProcessEnv = process.env,
log: (msg: string) => void = console.log,
): BackupResult {
const url = resolveBackupTarget(env)
// Log the resolved target BEFORE doing anything: a backup that quietly dumped the wrong
// database is indistinguishable from a correct one until the day you need the restore.
log(`[db] engine: postgres ${pgTargetDescription(url)}`)
const pgDump = findPgDump()
if (pgDump === null) {
throw new Error('[backup] pg_dump not found. Install the PostgreSQL client tools, or add pg_dump to PATH.')
}
const out = env['HQ_BACKUP_DIR'] || join(process.cwd(), 'backups')
const keep = Math.max(1, Number(env['HQ_BACKUP_KEEP'] || DEFAULT_KEEP))
mkdirSync(out, { recursive: true })
const file = join(out, backupFileName(new Date()))
// -Fc = custom, compressed, restorable with pg_restore. --no-owner keeps it portable.
try {
execFileSync(pgDump, ['-Fc', '--no-owner', '-f', file, url], { stdio: ['ignore', 'inherit', 'inherit'] })
} catch (e) {
throw new Error(`[backup] pg_dump failed: ${e instanceof Error ? e.message : String(e)}`)
}
const bytes = statSync(file).size
log(`✓ backup written: ${file} (${(bytes / 1024).toFixed(1)} KB)`)
const rotated = dumpsToRotate(readdirSync(out), keep)
for (const f of rotated) {
unlinkSync(join(out, f))
log(` rotated out: ${f}`)
}
const retained = readdirSync(out).filter((f) => DUMP_RE.test(f)).length
log(`retained ${retained} backup(s) in ${out} (HQ_BACKUP_KEEP=${keep})`)
return { file, bytes, rotated, retained }
}

@ -1,7 +1,6 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import fs from 'node:fs'
import pg from 'pg'
import { sqliteFilePath, type DB } from './db'
import type { DB } from './db'
import { PG_MIGRATIONS } from './migrations-pg'
/**
@ -125,60 +124,19 @@ export function resolveDbUrl(env: NodeJS.ProcessEnv = process.env, hardcoded = H
}
/**
* Same resolution, but hard-fails instead of silently falling back to a THROWAWAY SQLite
* database for one-off deploy/maintenance scripts, where creating a brand-new empty DB is
* never the right outcome (unlike the server, which legitimately creates one on first boot in
* dev this function is never called from server.ts).
*
* The guard is OUTCOME-based, not NODE_ENV-based. The original fix gated the hard-fail on
* `NODE_ENV === 'production'`, but NODE_ENV is exactly what these scripts don't reliably have:
* the server gets it from its service definition, while the scripts are run by hand
* (`npx tsx apps/hq/scripts/…`) from an operator shell with no NODE_ENV and no dotenv. So on
* the real prod box the old guard never fired, resolution returned `''`, and the script
* silently opened/created a brand-new empty SQLite file in `<cwd>/data` "succeeding" against
* a throwaway DB while production was never touched.
*
* Fix: whenever resolution selects SQLite, refuse UNLESS the target file already exists a
* migration/maintenance script must never CREATE a database, regardless of what NODE_ENV is or
* isn't set to. `hardcoded` and `sqliteExists` are both injectable so tests never touch a real
* Postgres URL or the real filesystem.
*
* Second guard (same family of bug, one step earlier): landing on SQLite at all must be a
* DECISION, never a default. An operator who follows a script's documented command with no
* `DATABASE_URL=` in front of it should be told so, not quietly pointed at whatever `./data/hq.db`
* happens to exist under their shell's cwd. So SQLite additionally requires an explicit opt-in
* `HQ_DB_TARGET=sqlite` or a `--sqlite` argv flag (`HQ_DATA_DIR=:memory:` counts too: nobody sets
* that by accident). Without one, the script refuses and the error names both options.
* `argv` is injectable for the same reason as the rest: tests never depend on the real process.
* Same resolution, but hard-fails instead of silently falling back to SQLite when running in
* production for one-off deploy scripts, where a throwaway SQLite write is never the right
* outcome (unlike the server, which legitimately runs SQLite in dev). `hardcoded` is
* injectable so tests can simulate the hardcoded URL being accidentally emptied out.
*/
export function requireDbUrlForEnv(
env: NodeJS.ProcessEnv = process.env,
hardcoded = HARDCODED_DATABASE_URL,
sqliteExists: (file: string) => boolean = fs.existsSync,
argv: readonly string[] = process.argv,
): string {
export function requireDbUrlForEnv(env: NodeJS.ProcessEnv = process.env, hardcoded = HARDCODED_DATABASE_URL): string {
const pgUrl = resolveDbUrl(env, hardcoded)
if (pgUrl === '') {
const file = sqliteFilePath(env['HQ_DATA_DIR'])
const optedIn =
file === ':memory:' ||
(env['HQ_DB_TARGET'] ?? '').trim().toLowerCase() === 'sqlite' ||
argv.includes('--sqlite')
if (!optedIn) {
throw new Error(
'[db] refusing to run: no Postgres target resolved (DATABASE_URL is unset), and SQLite ' +
'has not been explicitly requested — running against the wrong database is worse than ' +
'not running at all. Either set DATABASE_URL=postgres://… to target the real database, ' +
`or opt into SQLite explicitly with HQ_DB_TARGET=sqlite (or the --sqlite flag), which ` +
`would use ${file}.`,
)
}
if (file !== ':memory:' && !sqliteExists(file)) {
throw new Error(
`[db] refusing to run: this would CREATE a new SQLite database at ${file}. ` +
'Set DATABASE_URL to target Postgres, or point HQ_DATA_DIR at the existing database.',
)
}
if (pgUrl === '' && env['NODE_ENV'] === 'production') {
throw new Error(
'[db] refusing to run: NODE_ENV=production but no Postgres target resolved ' +
'(DATABASE_URL is unset and the hardcoded production URL is empty) — this would ' +
'otherwise silently create a throwaway SQLite database instead of touching production data.',
)
}
return pgUrl
}

@ -534,15 +534,6 @@ function migrate(db: SqliteRaw): void {
if (!cmCols.some((c) => c.name === 'created_at')) {
db.exec(`ALTER TABLE client_module ADD COLUMN created_at TEXT`)
}
// FIX 4 (regression): the APEX importer inserted client_module rows WITHOUT created_at
// (only assignModule stamped it), so every imported project — the ~300-client real book —
// permanently NULL'd out of the "Onboarding stalled" tile's age measurement. Backfill from
// installed_on where it's known; a row with neither is genuinely unknown-age and stays
// NULL (never fabricated a date). Runs every migrate() pass — idempotent (only touches rows
// still NULL), so it also heals DBs that already had the column from before this fix.
db.exec(
`UPDATE client_module SET created_at = installed_on WHERE created_at IS NULL AND installed_on IS NOT NULL`,
)
const modCols2 = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[]
if (!modCols2.some((c) => c.name === 'field_spec')) {
db.exec(`ALTER TABLE module ADD COLUMN field_spec TEXT NOT NULL DEFAULT '[]'`)

@ -1138,15 +1138,12 @@ export async function commitApexFull(
)
if (existingCm === undefined) {
await db.run(
// FIX 4 (regression): stamp created_at like assignModule does — otherwise every
// imported project with zero ticks has a NULL created_at and is permanently
// invisible on the "Onboarding stalled" tile, exactly the population it exists for.
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition, active,
installed_on, provider, username, password_enc, details, remark, field_values, created_at)
VALUES (?, ?, ?, ?, 'yearly', 'standard', 1, ?, ?, ?, ?, ?, ?, ?, ?)`,
installed_on, provider, username, password_enc, details, remark, field_values)
VALUES (?, ?, ?, ?, 'yearly', 'standard', 1, ?, ?, ?, ?, ?, ?, ?)`,
a.id, a.clientId, moduleId, a.status, a.installedOn,
a.provider, a.username, a.passwordEnc, JSON.stringify(a.details), a.remark,
JSON.stringify(a.fieldValues), now,
JSON.stringify(a.fieldValues),
)
} else if (a.serviceTouched) {
// Client pre-dated this import with a live assignment: service data wins.

@ -77,9 +77,11 @@ export async function migrateOnboardingTemplates(
// 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[] = []
let alreadyNew = true
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 }
if (!templateKeys.has(r.key)) alreadyNew = false
const isCustom = !templateKeys.has(r.key) && KEY_MAP[r.key] === undefined && !KNOWN_LEGACY_KEYS.has(r.key)
if (isCustom) { 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) {
@ -97,17 +99,10 @@ export async function migrateOnboardingTemplates(
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))
// Idempotency: if the project's keys already ARE exactly the template, skip.
const oldKeySet = new Set(oldRows.map((r) => r.key))
const identical = alreadyNew && oldKeySet.size === templateKeys.size
&& [...templateKeys].every((k) => oldKeySet.has(k))
if (identical) continue
projects++
await db.transaction(async () => {

@ -373,12 +373,4 @@ END;
id: '018-client-module-created-at',
sql: `ALTER TABLE client_module ADD COLUMN IF NOT EXISTS created_at text;`,
},
{
// FIX 4 (regression): the APEX importer inserted client_module rows WITHOUT created_at
// (only assignModule stamped it going forward), so every one of the ~300 imported clients'
// projects permanently NULL'd out of the stalled-onboarding age measurement. Backfill from
// installed_on where known; a row with neither stays NULL (never fabricated).
id: '019-client-module-created-at-backfill',
sql: `UPDATE client_module SET created_at = installed_on WHERE created_at IS NULL AND installed_on IS NOT NULL;`,
},
]

@ -45,25 +45,13 @@ const STEP_STATUS: Record<string, string> = {
}
/**
* The two things ticking a milestone does to its parent `client_module`, applied in the
* caller's transaction and audited together:
*
* 1. **Status**, advanced to the step's target never downgraded.
* 2. **installed_on / trained_on**, stamped from the triggering milestone's own done_on.
* This also replaces the removed UI date-writers (the Client Detail redesign dropped
* `RowDate` / the summary-table date columns without a replacement), so a tick is now the
* ONLY way those columns get filled.
*
* The two are INDEPENDENT (bug fix): the date used to be stamped only as a side effect of a
* status upgrade, so on the ~300 APEX-imported projects already at `live`, the furthest
* status ticking Installation or Training upgraded nothing and therefore recorded nothing,
* leaving the Module Directory's "Installed" column blank forever with no way to fill it.
* Ticking the step now stamps the date whether or not the coarse status moves. Still
* write-once: a date already on file (from the APEX importer or an earlier tick) is never
* overwritten. If neither the status nor a date would change, nothing is written and no audit
* row is added a re-tick stays a no-op.
* 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 applyStepEffects(
async function advanceStatusForStep(
db: DB, userId: string, clientModuleId: string, key: string, doneOn: string | null,
): Promise<void> {
const target = STEP_STATUS[key]
@ -71,14 +59,11 @@ async function applyStepEffects(
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
const sets: string[] = []
const args: unknown[] = []
const before: Record<string, unknown> = {}
const after: Record<string, unknown> = {}
if ((STATUS_RANK[cm.status] ?? 0) < (STATUS_RANK[target] ?? 0)) { // never downgrade
sets.push('status=?'); args.push(target)
before['status'] = cm.status; after['status'] = target
}
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
@ -87,7 +72,6 @@ async function applyStepEffects(
sets.push('trained_on=?'); args.push(doneOn)
before['trainedOn'] = null; after['trainedOn'] = doneOn
}
if (sets.length === 0) return
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)
@ -282,13 +266,6 @@ export async function listProjectMilestones(db: DB, clientModuleId: string): Pro
* 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.
*
* Link lifecycle (bug fix): re-ticking an ALREADY-DONE step without resending its ids keeps
* the links already on file. They used to be written as `done ? linked : null`, so any tick
* that didn't carry the ids wiped them most visibly `bulkSetMilestone`, which never passes
* them, so one bulk re-tick silently cut every project's step loose from its payment /
* document. Passing an explicit empty string still clears a link (that is the "unlink" the
* picker sends), and unticking still clears both, as before.
*/
export async function setMilestone(
db: DB, userId: string, clientModuleId: string, key: string, done: boolean,
@ -305,9 +282,7 @@ export async function setMilestone(
throw new Error('doneOn must be YYYY-MM-DD')
}
let stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null
// Nothing supplied for an already-done step means "leave the link alone", not "clear it".
const wasDone = before.done === 1
let linkedPayment: string | null = paymentId === undefined && wasDone ? before.payment_id : 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=?)`,
@ -317,7 +292,7 @@ export async function setMilestone(
linkedPayment = paymentId
if (doneOn === undefined) stamp = pay.received_on // link date wins unless caller forced one
}
let linkedDocument: string | null = documentId === undefined && wasDone ? before.document_id : null
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=?)`,
@ -326,17 +301,14 @@ export async function setMilestone(
if (doc === undefined) throw new Error('Document not found for this client')
linkedDocument = documentId
}
// Unticking always clears both links, whatever was resolved above.
const finalPayment = done ? linkedPayment : null
const finalDocument = done ? linkedDocument : null
await db.run(
`UPDATE project_milestone SET done=?, done_on=?, payment_id=?, document_id=?
WHERE client_module_id=? AND key=?`,
done ? 1 : 0, stamp, finalPayment, finalDocument, clientModuleId, 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: finalPayment, documentId: finalDocument })
if (done) await applyStepEffects(db, userId, clientModuleId, key, stamp)
{ key, done, doneOn: stamp, paymentId: linkedPayment, documentId: linkedDocument })
if (done) await advanceStatusForStep(db, userId, clientModuleId, key, stamp)
return listProjectMilestones(db, clientModuleId)
})
}
@ -367,13 +339,9 @@ export async function addProjectMilestone(
* 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 logic a single tick uses instead of a bare UPDATE.
* 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.
*
* A bulk call never carries per-project ids, so it relies on `setMilestone` PRESERVING the
* links already on an already-done step (see there) otherwise re-ticking a step in bulk
* would wipe every payment/document link that had been set one at a time.
*/
export async function bulkSetMilestone(
db: DB, userId: string, clientModuleIds: string[], key: string, done: boolean, doneOn?: string,
@ -446,16 +414,6 @@ export async function projectsPendingMilestone(db: DB, key: string): Promise<Pen
)
}
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
@ -465,15 +423,12 @@ export interface StalledProjectsResult {
* 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.
* guessed at the intent is "parked past N days", not "we don't know, so assume forever".
* 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 }>(
export async function stalledProjects(db: DB, olderThanDays: number, today: string): Promise<PendingProject[]> {
const rows = 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
@ -486,19 +441,11 @@ export async function stalledProjects(
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 }
return rows
.filter((r) => {
if (r.last_done !== null) return r.last_done < cutoff
if (r.created_at !== null) return r.created_at.slice(0, 10) < cutoff
return false
})
.map(({ last_done: _last_done, created_at: _created_at, ...rest }) => rest)
}

@ -1,51 +0,0 @@
// apps/hq/test/backup.test.ts — `npm run backup` (D33) must find the PRODUCTION database.
// It used to read process.env.DATABASE_URL directly and exit when it was unset — but on the
// prod box DATABASE_URL is not exported; the connection is RESOLVED (D19). So the backup
// command could never reach the real data. It now goes through the one shared resolver, and
// a SQLite outcome is a loud error rather than a pg_dump against a file it cannot read.
import { describe, it, expect } from 'vitest'
import { backupFileName, dumpsToRotate, resolveBackupTarget } from '../src/backup'
describe('resolveBackupTarget (same resolver as the server + every maintenance script)', () => {
it('uses DATABASE_URL when it is set', () => {
expect(resolveBackupTarget({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
})
it('resolves the production target with no DATABASE_URL exported — the whole point of the fix', () => {
// NODE_ENV=production with no DATABASE_URL used to be an instant "DATABASE_URL is not set"
// exit; it must now resolve to the same Postgres the server picks.
const url = resolveBackupTarget({ NODE_ENV: 'production' })
expect(url).toMatch(/^postgres:\/\//)
})
it('refuses (never silently no-ops) when nothing resolves a target', () => {
expect(() => resolveBackupTarget({})).toThrow(/refusing to run/i)
})
it('says WHY, naming pg_dump, the SQLite path it landed on, and the SQLite snapshot script', () => {
const err = (() => { try { resolveBackupTarget({ HQ_DATA_DIR: 'D:/hq-data' }); return '' } catch (e) { return String(e) } })()
expect(err).toMatch(/pg_dump/)
expect(err).toMatch(/backup-hq\.sh/)
expect(err).toMatch(/hq\.db/)
expect(err).toMatch(/DATABASE_URL=postgres/)
})
})
describe('backup file naming + rotation', () => {
it('names dumps hq-YYYYMMDD-HHMMSS.dump', () => {
expect(backupFileName(new Date(2026, 6, 19, 2, 5, 9))).toBe('hq-20260719-020509.dump')
})
it('rotates out everything past the newest KEEP, ignoring unrelated files', () => {
const files = [
'hq-20260101-000000.dump', 'hq-20260102-000000.dump', 'hq-20260103-000000.dump',
'notes.txt', 'hq-old.dump',
]
expect(dumpsToRotate(files, 2)).toEqual(['hq-20260101-000000.dump'])
expect(dumpsToRotate(files, 14)).toEqual([])
})
it('keeps at least one dump however low KEEP is set', () => {
expect(dumpsToRotate(['hq-20260101-000000.dump'], 0)).toEqual([])
})
})

@ -4,13 +4,10 @@
// the scripts computed it as `DATABASE_URL ?? ''` (no production fallback), so on the
// production box — where DATABASE_URL is unset — each script silently opened a brand-new
// empty SQLite file instead of the real Postgres.
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { describe, it, expect } from 'vitest'
import { pgTargetDescription, requireDbUrlForEnv, resolveDbUrl } from '../src/db-pg'
const HARDCODED = 'postgres://u:p@h:5432/db'
const HARDCODED = 'postgres://postgres:inv123@host.docker.internal:5432/hq'
describe('resolveDbUrl (D19 engine selection, the one shared resolver)', () => {
it('DATABASE_URL wins regardless of NODE_ENV', () => {
@ -29,103 +26,16 @@ describe('resolveDbUrl (D19 engine selection, the one shared resolver)', () => {
})
})
describe('requireDbUrlForEnv (deploy-script guard — OUTCOME-based, not NODE_ENV-based)', () => {
it('behaves exactly like resolveDbUrl when a Postgres target is available (no SQLite file check runs at all)', () => {
describe('requireDbUrlForEnv (deploy-script guard — hard-fail instead of a throwaway SQLite write)', () => {
it('behaves exactly like resolveDbUrl when a Postgres target is available', () => {
expect(requireDbUrlForEnv({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
expect(requireDbUrlForEnv({ NODE_ENV: 'production' }, HARDCODED)).toBe(HARDCODED)
expect(requireDbUrlForEnv({})).toBe('') // dev with no env: SQLite is fine, no hard-fail
})
it('resolves to SQLite without hard-failing when it is opted into and the target file exists (mocked fs check)', () => {
expect(requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, () => true)).toBe('')
// Even NODE_ENV=production is fine as long as the SQLite file it would land on already exists.
expect(requireDbUrlForEnv({ NODE_ENV: 'production', HQ_DB_TARGET: 'sqlite' }, '', () => true)).toBe('')
})
it(':memory: never hard-fails — the guard never even calls the file-existence check', () => {
expect(
requireDbUrlForEnv({ HQ_DATA_DIR: ':memory:' }, undefined, () => {
throw new Error('must not be called for :memory:')
}),
).toBe('')
})
it('refuses when resolution selects SQLite and the target does not exist — regardless of NODE_ENV', () => {
// This is the original prod bug: an operator shell has no NODE_ENV set at all, so the old
// NODE_ENV==='production' gate never fired. The guard must fire on the OUTCOME instead.
const optIn = { HQ_DB_TARGET: 'sqlite' }
expect(() => requireDbUrlForEnv(optIn, undefined, () => false)).toThrow(/refusing to run/i)
expect(() => requireDbUrlForEnv(optIn, undefined, () => false)).toThrow(/CREATE a new SQLite database/)
// Also still refuses in the NODE_ENV=production case (hardcoded emptied out + no existing file).
expect(() => requireDbUrlForEnv({ NODE_ENV: 'production', HQ_DB_TARGET: 'sqlite' }, '', () => false))
.toThrow(/refusing to run/i)
})
})
describe('requireDbUrlForEnv — SQLite must be an explicit opt-in, never an accident', () => {
// The bug this closes: apply-onboarding-pipeline.ts documented its own invocation WITHOUT a
// `DATABASE_URL=` prefix. An operator who copy-pastes that on the prod box resolves to SQLite;
// if a stale ./data/hq.db happens to exist under their cwd, the file-existence guard passes and
// the migration "succeeds" against the wrong database. Landing on SQLite must be a decision.
const existingFile = (): boolean => true
it('refuses when nothing selects a target, even though the SQLite file exists', () => {
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/refusing to run/i)
})
it('names BOTH escape hatches in the error — DATABASE_URL and the SQLite opt-in', () => {
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/DATABASE_URL=postgres/)
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/HQ_DB_TARGET=sqlite/)
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/--sqlite/)
})
it('accepts HQ_DB_TARGET=sqlite (case/space tolerant)', () => {
expect(requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, existingFile)).toBe('')
expect(requireDbUrlForEnv({ HQ_DB_TARGET: ' SQLite ' }, undefined, existingFile)).toBe('')
})
it('accepts a --sqlite argv flag (alongside the script\'s own flags)', () => {
expect(requireDbUrlForEnv({}, undefined, existingFile, ['node', 'script.ts', '--force', '--sqlite'])).toBe('')
})
it('rejects an unrelated HQ_DB_TARGET value rather than guessing', () => {
expect(() => requireDbUrlForEnv({ HQ_DB_TARGET: 'postgres' }, undefined, existingFile)).toThrow(/refusing to run/i)
})
it('the opt-in does NOT weaken the "never CREATE a database" guard', () => {
expect(() => requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, () => false))
.toThrow(/CREATE a new SQLite database/)
})
it('DATABASE_URL needs no opt-in — Postgres is the intended target and never touches the flag', () => {
expect(requireDbUrlForEnv({ DATABASE_URL: 'postgres://x/y' }, undefined, () => false, [])).toBe('postgres://x/y')
})
})
describe('requireDbUrlForEnv — real filesystem (the actual script entry path, no mocks)', () => {
it('refuses a non-existent SQLite path', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-db-resolve-'))
try {
expect(() => requireDbUrlForEnv({ HQ_DATA_DIR: dir, HQ_DB_TARGET: 'sqlite' }))
.toThrow(/CREATE a new SQLite database/)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
it('accepts an existing SQLite path once SQLite is opted into', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-db-resolve-'))
try {
fs.writeFileSync(path.join(dir, 'hq.db'), '')
expect(requireDbUrlForEnv({ HQ_DATA_DIR: dir, HQ_DB_TARGET: 'sqlite' })).toBe('')
// …and still refuses the same existing path with no opt-in at all.
expect(() => requireDbUrlForEnv({ HQ_DATA_DIR: dir })).toThrow(/HQ_DB_TARGET=sqlite/)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
it(':memory: still works with no filesystem check', () => {
expect(requireDbUrlForEnv({ HQ_DATA_DIR: ':memory:' })).toBe('')
it('hard-fails instead of silently returning SQLite when NODE_ENV=production resolves nothing', () => {
// Simulates the hardcoded URL having been accidentally emptied out.
expect(() => requireDbUrlForEnv({ NODE_ENV: 'production' }, '')).toThrow(/refusing to run/i)
})
})

@ -1,12 +1,6 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { writeAudit, listAudit } from '../src/audit'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule } from '../src/repos-modules'
describe('hq db', () => {
it('creates every HQ-1 table', async () => {
@ -26,55 +20,4 @@ describe('hq db', () => {
expect(rows[0]!.action).toBe('update')
expect(JSON.parse(rows[0]!.after_json!)).toEqual({ name: 'Acme Ltd' })
})
// FIX 4 (regression): the APEX importer inserted client_module rows WITHOUT created_at, so
// every imported project with zero ticks was permanently NULL-aged. migrate() must backfill
// it from installed_on where that's known — using a real (non-:memory:) DB reopened twice,
// since the backfill runs as part of migrate() on every open, not just column-creation.
describe('client_module.created_at backfill (FIX 4)', () => {
it('backfills created_at from installed_on for a legacy row missing it', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-db-backfill-'))
try {
let db = openDb(dir)
await seedIfEmpty(db)
const client = await createClient(db, 'u1', { name: 'Legacy Client', stateCode: '32' })
const mod = await createModule(db, 'u1', { code: 'LEGACYBF', name: 'Legacy Module' })
const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' })
// Simulate the actual APEX-importer bug: installed_on known, created_at never stamped.
await db.run(`UPDATE client_module SET created_at=NULL, installed_on=? WHERE id=?`, '2025-11-03', cm.id)
await db.close()
db = openDb(dir) // reopening re-runs migrate() — the backfill must heal it unprompted
const row = await db.get<{ created_at: string | null; installed_on: string | null }>(
`SELECT created_at, installed_on FROM client_module WHERE id=?`, cm.id,
)
expect(row!.created_at).toBe('2025-11-03')
await db.close()
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
it('leaves created_at NULL when installed_on is also unknown — never fabricates a date', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-db-backfill-'))
try {
let db = openDb(dir)
await seedIfEmpty(db)
const client = await createClient(db, 'u1', { name: 'Legacy Client 2', stateCode: '32' })
const mod = await createModule(db, 'u1', { code: 'LEGACYBF2', name: 'Legacy Module 2' })
const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' })
await db.run(`UPDATE client_module SET created_at=NULL WHERE id=?`, cm.id) // installed_on stays NULL too
await db.close()
db = openDb(dir)
const row = await db.get<{ created_at: string | null }>(
`SELECT created_at FROM client_module WHERE id=?`, cm.id,
)
expect(row!.created_at).toBeNull()
await db.close()
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
})
})

@ -201,10 +201,10 @@ const cmFor = async (db: DB, clientCode: string, moduleCode: string) =>
db.get<{
status: string; kind: string; provider: string | null; username: string | null
password_enc: string | null; details: string; remark: string | null; installed_on: string | null
field_values: string; created_at: string | null
field_values: string
}>(
`SELECT cm.status, cm.kind, cm.provider, cm.username, cm.password_enc, cm.details, cm.remark,
cm.installed_on, cm.field_values, cm.created_at
cm.installed_on, cm.field_values
FROM client_module cm JOIN module m ON m.id=cm.module_id JOIN client c ON c.id=cm.client_id
WHERE c.code=? AND m.code=?`, clientCode, moduleCode)
@ -281,9 +281,6 @@ describe('APEX full importer v2 (real-export shapes)', () => {
const atm = (await cmFor(db, '103', 'ATM'))!
expect(atm.status).toBe('live')
expect(atm.provider).toBe('EWIRE')
// FIX 4 (regression): the importer must stamp created_at like assignModule does — a NULL
// here means the project permanently vanishes off the "Onboarding stalled" tile.
expect(atm.created_at).not.toBeNull()
const mob = (await cmFor(db, '103', 'MOBILEAPP'))!
expect(JSON.parse(mob.field_values)).toEqual({ contact: '8301932166' }) // D21 declared field
expect(JSON.parse(mob.details)).toEqual([])

@ -313,48 +313,6 @@ describe('onboarding template migration', () => {
expect(audits[0]!.after_json).toContain('custom_ef56gh78')
})
// Regression (FIX 3): the idempotency check used to flag `alreadyNew = false` for ANY row
// whose key wasn't a template key — including the deliberately-preserved `custom_*` ad-hoc
// steps — so a project carrying one was never "identical" and got DELETE-and-rebuilt on
// EVERY run (new row ids, a fresh audit row each time), contradicting "a second run does
// nothing". Idempotency must be computed over non-custom rows only.
it('is idempotent on a project carrying a custom_* ad-hoc step (second run is a true no-op)', async () => {
const cmId = await makeSmsClientModule()
await seedOldGenericRows(cmId)
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort)
VALUES (?,?,?,?,?,?,?)`,
'custom-1', cmId, 'custom_ab12cd34', 'AWS region confirmed', 1, '2026-05-01', 10,
)
const first = await migrateOnboardingTemplates(db)
expect(first.projects).toBe(1)
const afterFirst = await listProjectMilestones(db, cmId)
const rowIdsAfterFirst = (await db.all<{ id: string }>(
`SELECT id FROM project_milestone WHERE client_module_id=? ORDER BY sort`, cmId,
)).map((r) => r.id)
const second = await migrateOnboardingTemplates(db)
expect(second.projects).toBe(0) // true no-op, not a DELETE-and-rebuild
expect(second.carried).toBe(0)
expect(second.dropped).toBe(0)
expect(second.preserved).toBe(0)
const afterSecond = await listProjectMilestones(db, cmId)
expect(afterSecond).toEqual(afterFirst)
const rowIdsAfterSecond = (await db.all<{ id: string }>(
`SELECT id FROM project_milestone WHERE client_module_id=? ORDER BY sort`, cmId,
)).map((r) => r.id)
// Same row ids — proof the second run did not delete-and-reinsert.
expect(rowIdsAfterSecond).toEqual(rowIdsAfterFirst)
// No second audit row written for this client_module on the no-op pass.
const audits = await db.all<{ n: number }>(
`SELECT COUNT(*) AS n FROM audit_log WHERE action='migrate_onboarding' AND entity_id=?`, cmId,
)
expect((audits[0] as unknown as { n: number }).n).toBe(1)
})
it('a known old-generic key with no forward mapping (amc_start) is still dropped, not treated as custom', async () => {
const cmId = await makeSmsClientModule()
await seedOldGenericRows(cmId) // includes ticked 'installed'/'go_live' and un-ticked 'amc_start'

@ -1,113 +0,0 @@
// apps/hq/test/milestone-link-preservation.test.ts — re-ticking an already-done step must not
// wipe the payment / document link it already carries.
//
// The bug: setMilestone wrote `payment_id`/`document_id` as `done ? linked : null`, so any tick
// that didn't resend the ids cleared them. bulkSetMilestone routes through setMilestone and
// never passes ids at all (there is one key for many projects), so a single bulk re-tick — the
// onboarding-cleanup tool — silently cut every project's step loose from the payment or
// quotation it had been linked to one at a time. Unticking must still clear both.
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice, assignModule } from '../src/repos-modules'
import { createDraft } from '../src/repos-documents'
import { recordPayment } from '../src/repos-payments'
import { bulkSetMilestone, listProjectMilestones, setMilestone } 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' })
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2020-01-01' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const step = async (key: string) => (await listProjectMilestones(db, cm.id)).find((s) => s.key === key)!
return { db, clientId: c.id, moduleId: m.id, cmId: cm.id, step }
}
describe('bulk re-tick preserves the payment link (5a)', () => {
it('tick with a payment link → bulk re-tick keeps it → bulk untick clears it', async () => {
const { db, clientId, cmId, step } = await world()
const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
})
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
expect((await step('payment_received')).paymentId).toBe(pay.payment.id)
// The bulk tool carries no ids — the stored link must survive.
const res = await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', true, '2026-06-01')
expect(res.updated).toBe(1)
const after = await step('payment_received')
expect(after.done).toBe(true)
expect(after.paymentId).toBe(pay.payment.id)
// Unticking still cuts the link loose.
await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', false)
const cleared = await step('payment_received')
expect(cleared.done).toBe(false)
expect(cleared.doneOn).toBeNull()
expect(cleared.paymentId).toBeNull()
})
it('a single re-tick that sends no ids keeps the link too', async () => {
const { db, clientId, cmId, step } = await world()
const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
})
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
await setMilestone(db, 'u1', cmId, 'payment_received', true, '2026-06-01')
const s = await step('payment_received')
expect(s.doneOn).toBe('2026-06-01') // the date the caller did send still moves
expect(s.paymentId).toBe(pay.payment.id)
})
it('an explicit empty id is still an unlink, not a preserve', async () => {
const { db, clientId, cmId, step } = await world()
const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
})
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, '')
expect((await step('payment_received')).paymentId).toBeNull()
})
it('the preserved link is what the audit row reports', async () => {
const { db, clientId, cmId } = await world()
const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
})
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', true, '2026-06-01')
const audits = await db.all<{ after_json: string | null }>(
`SELECT after_json FROM audit_log WHERE action='set_milestone' ORDER BY id`,
)
expect(JSON.parse(audits.at(-1)!.after_json!)).toMatchObject({ paymentId: pay.payment.id })
})
})
describe('bulk re-tick preserves the document link (5a)', () => {
it('tick with a quotation link → bulk re-tick keeps it → untick clears it', async () => {
const { db, clientId, moduleId, cmId, step } = await world()
const doc = await createDraft(db, 'u1', {
docType: 'QUOTATION', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }],
})
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, '2026-05-01', undefined, doc.id)
expect((await step('quotation_sent')).documentId).toBe(doc.id)
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', true, '2026-06-01')
expect((await step('quotation_sent')).documentId).toBe(doc.id)
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', false)
expect((await step('quotation_sent')).documentId).toBeNull()
})
it('a step that was never done still starts with no link (nothing to preserve)', async () => {
const { db, cmId, step } = await world()
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', true, '2026-06-01')
const s = await step('quotation_sent')
expect(s.done).toBe(true)
expect(s.documentId).toBeNull()
expect(s.paymentId).toBeNull()
})
})

@ -110,63 +110,6 @@ describe('milestone-driven installed_on / trained_on stamping (FIX 5)', () => {
})
})
// The date-stamp used to ride on the status UPGRADE, so a project already at the furthest
// status could never record one. That is the state of all ~300 APEX-imported projects: they
// come in at 'live', ticking Installation/Training upgrades nothing, and — with the UI's own
// date writers removed — installed_on/trained_on became permanently unfillable, leaving the
// Module Directory's "Installed" column blank forever. Ticking now stamps regardless.
describe('date stamping is independent of the status upgrade (APEX-imported projects at live)', () => {
it("ticking 'installation' on a project already at live records installed_on and leaves the status alone", async () => {
const { db, cmId } = await world()
await updateClientModule(db, 'u1', cmId, { status: 'live' })
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const cm = await getClientModule(db, cmId)
expect(cm!.installedOn).toBe('2026-03-25')
expect(cm!.status).toBe('live') // unchanged — no downgrade, no spurious upgrade
})
it("ticking 'training' on a project already at live records trained_on and leaves the status alone", async () => {
const { db, cmId } = await world()
await updateClientModule(db, 'u1', cmId, { status: 'live' })
await setMilestone(db, 'u1', cmId, 'training', true, '2026-05-02')
const cm = await getClientModule(db, cmId)
expect(cm!.trainedOn).toBe('2026-05-02')
expect(cm!.status).toBe('live')
})
it('still never overwrites a date already on file, even with no status move', async () => {
const { db, cmId } = await world()
await updateClientModule(db, 'u1', cmId, { status: 'live', installedOn: '2025-01-01' })
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
expect((await getClientModule(db, cmId))!.installedOn).toBe('2025-01-01')
})
it('the audit payload records the date-only change (no status key, since status did not move)', async () => {
const { db, cmId } = await world()
await updateClientModule(db, 'u1', cmId, { status: 'live' })
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const audits = await db.all<{ after_json: string | null }>(
`SELECT after_json FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update' ORDER BY id`,
cmId,
)
// The last 'update' row is the tick's own (the earlier one is the status seed above).
const after = JSON.parse(audits.at(-1)!.after_json!) as Record<string, unknown>
expect(after['installedOn']).toBe('2026-03-25')
expect(after['status']).toBeUndefined()
})
it('a re-tick that changes nothing writes no client_module audit row at all', async () => {
const { db, cmId } = await world()
await updateClientModule(db, 'u1', cmId, { status: 'live', installedOn: '2025-01-01' })
const countBefore = (await db.all(
`SELECT id FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update'`, cmId)).length
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const countAfter = (await db.all(
`SELECT id FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update'`, cmId)).length
expect(countAfter).toBe(countBefore)
})
})
// Pre-ship audit FIX E: STEP_STATUS only had keys from the NEW template vocabulary
// (installation/training/go_live/quotation_approved), but TAIL_FALLBACK — used by any module
// with no seeded/owner-configured tail (e.g. WHATSAPP, ATM, AMC) — still uses the legacy

@ -34,12 +34,9 @@ describe('GET /projects/stalled — hostile ?days= must not crash the server', (
const { base, staffH } = await httpWorld()
const res = await fetch(`${base}/projects/stalled`, { headers: staffH })
expect(res.status).toBe(200)
const body = await res.json() as { ok: boolean; projects: unknown[]; unknownAgeExcluded: number }
const body = await res.json() as { ok: boolean; projects: unknown[] }
expect(body.ok).toBe(true)
expect(Array.isArray(body.projects)).toBe(true)
// FIX 4: the count of pending projects excluded for unprovable age travels alongside the
// list itself now, so the UI can surface "N of unknown age" instead of a silent drop.
expect(typeof body.unknownAgeExcluded).toBe('number')
})
it('a sane ?days= still works normally', async () => {

@ -27,11 +27,10 @@ describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
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.rows.map((p) => p.clientModuleId)).toContain(cm.id)
const row = stalled.rows.find((p) => p.clientModuleId === cm.id)!
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')
expect(stalled.unknownAgeExcluded).toBe(0)
})
it('excludes a recently-progressed project', async () => {
@ -40,8 +39,7 @@ describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
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.rows).toHaveLength(0)
expect(stalled.unknownAgeExcluded).toBe(0)
expect(stalled).toHaveLength(0)
})
it('excludes projects with no pending steps (fully ticked) and inactive/live projects', async () => {
@ -52,7 +50,7 @@ describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
await setMilestone(db, 'u1', done.id, key, true, '2026-01-01')
}
const stalled = await stalledProjects(db, 1, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(done.id)
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')
})
@ -71,8 +69,7 @@ describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
// to be — this test is about the QUERY logic, not the real-time write path.
await db.run(`UPDATE client_module SET created_at=? WHERE id=?`, '2026-07-19T09:00:00.000Z', cm.id)
const stalled = await stalledProjects(db, 30, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
expect(stalled.unknownAgeExcluded).toBe(0)
expect(stalled.map((p) => p.clientModuleId)).not.toContain(cm.id)
})
it('a project created long ago with zero ticks IS stalled', async () => {
@ -80,7 +77,7 @@ describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await db.run(`UPDATE client_module SET created_at=? WHERE id=?`, '2025-01-01T09:00:00.000Z', cm.id)
const stalled = await stalledProjects(db, 30, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).toContain(cm.id)
expect(stalled.map((p) => p.clientModuleId)).toContain(cm.id)
})
it('a no-tick project with an UNKNOWN creation date (created_at NULL) is excluded, not assumed ancient', async () => {
@ -90,18 +87,7 @@ describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
// doesn't stamp it) — created_at is genuinely unknown.
await db.run(`UPDATE client_module SET created_at=NULL WHERE id=?`, cm.id)
const stalled = await stalledProjects(db, 0, '2026-07-19') // even a 0-day cutoff must not flag it
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
})
// FIX 4 (no silent caps): the row above used to just vanish with no trace. It must now be
// counted so the UI can surface "N of unknown age" instead of silently under-reporting.
it('a no-tick row with NULL created_at lands in the unknownAgeExcluded count, not just silently dropped', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await db.run(`UPDATE client_module SET created_at=NULL WHERE id=?`, cm.id)
const stalled = await stalledProjects(db, 0, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
expect(stalled.unknownAgeExcluded).toBe(1)
expect(stalled.map((p) => p.clientModuleId)).not.toContain(cm.id)
})
it('assignModule itself stamps a real (non-null) created_at', async () => {

@ -571,18 +571,13 @@ refresh no-ops with a clear message, it never stores anything unencrypted.
(Superseded by D31/D32: the key was removed; the pull now runs keyless.)
## D33 — Database backups (ops, 2026-07-19)
`apps/hq/scripts/backup.ts``apps/hq/src/backup.ts` (`npm run backup`) `pg_dump`s the
database to a timestamped, compressed,
`scripts/backup.mjs` (`npm run backup`) `pg_dump`s the database to a timestamped, compressed,
`pg_restore`-able file under `./backups` (gitignored), keeping the newest `HQ_BACKUP_KEEP`
(default 14). It finds `pg_dump` on PATH or in the Windows `C:\Program Files\PostgreSQL\*\bin`
install. Restore: `pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" <dump>`.
Priority rose with D32 — dumps now contain **plaintext credentials**, so the `backups/` dir
must stay access-controlled and off shared drives. Still pending in the ops slice: schedule
it (Task Scheduler / cron), off-box copy, and getting the DB password out of `server.ts`.
It resolves its target through the SAME shared resolver as the server and the maintenance
scripts (`resolveDbUrl`, D19) and logs the resolved host/db before dumping — the original
`scripts/backup.mjs` read `DATABASE_URL` straight off the environment and so could never
reach production, where that variable is not exported.
## D34 — Employee master import + invited/first-login flag (2026-07-19)
Imported the 11-row employee master (`empmaster.xlsx`) into `staff_user` via

@ -11,7 +11,7 @@
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc -p tsconfig.json && npm run typecheck --workspaces --if-present",
"backup": "tsx apps/hq/scripts/backup.ts"
"backup": "node scripts/backup.mjs"
},
"devDependencies": {
"@types/node": "^22.10.0",

@ -194,22 +194,6 @@ input.wf:focus, select.wf:focus, textarea.wf:focus {
.wf-toolbar .spacer { flex: 1; }
/* ================= tables ================= */
/* Horizontal-scroll wrapper (emitted by DataTable) so a wide table scrolls sideways
inside its own box instead of silently losing its rightmost columns off-page at
any width (rule 8 no silent caps). Bounded to a fraction of the viewport height
and given a matching `overflow-y` so it is a real, self-contained scroll box:
`overflow-x: auto` alone would still leave `overflow-y` at its initial `visible`,
but per the CSS overflow spec a `visible`/non-`visible` pairing computes the
`visible` side to `auto` too so without this the wrapper silently becomes an
unbounded vertical scroll container (height: auto, scrollTop always 0) and steals
`table.wf th`'s sticky-positioning context from `.hq-content` (the page's real
scroller) for nothing in return: the header can never offset and just scrolls out
of view with the rest of the page. Giving the wrapper an actual bound restores a
working scrollport, so the sticky header sticks again now against this box
rather than the page. A short table never reaches the bound (renders exactly as
before, no inner scrollbar); a long one scrolls inside it, same pattern already
used for `.dash-list`. */
.wf-table-wrap { overflow: auto; max-height: 70vh; }
table.wf {
border-collapse: separate; border-spacing: 0; width: 100%;
background: var(--bg-raised); border: 1px solid var(--border);

@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* SiMS HQ database backup (ops slice). Dumps the Postgres database to a timestamped,
* compressed file under ./backups (or $HQ_BACKUP_DIR), then rotates to the newest N.
*
* DATABASE_URL=postgres://user:pass@host:5432/db node scripts/backup.mjs
* npm run backup (from apps/hq passes DATABASE_URL through)
*
* Restore a dump with:
* pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" backups/hq-<ts>.dump
*
* SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext
* logins. Keep the backups/ directory access-controlled and off any shared drive; it is
* gitignored so it can never be committed.
*
* Env: DATABASE_URL (required), HQ_BACKUP_DIR (default ./backups), HQ_BACKUP_KEEP (default 14).
*/
import { execFileSync } from 'node:child_process'
import { mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from 'node:fs'
import { join } from 'node:path'
const url = process.env.DATABASE_URL
if (!url || url.trim() === '') {
console.error('DATABASE_URL is not set. Run: DATABASE_URL=postgres://… node scripts/backup.mjs')
process.exit(1)
}
/** Locate pg_dump: PATH first, then a standard Windows PostgreSQL install. */
function findPgDump() {
try {
execFileSync(process.platform === 'win32' ? 'where' : 'which', ['pg_dump'], { stdio: 'ignore' })
return 'pg_dump'
} catch { /* not on PATH */ }
if (process.platform === 'win32') {
const base = 'C:/Program Files/PostgreSQL'
try {
for (const v of readdirSync(base).sort().reverse()) {
const cand = join(base, v, 'bin', 'pg_dump.exe')
if (existsSync(cand)) return cand
}
} catch { /* no install dir */ }
}
return null
}
const pgDump = findPgDump()
if (pgDump === null) {
console.error('pg_dump not found. Install the PostgreSQL client tools, or add pg_dump to PATH.')
process.exit(1)
}
const OUT = process.env.HQ_BACKUP_DIR || join(process.cwd(), 'backups')
const KEEP = Math.max(1, Number(process.env.HQ_BACKUP_KEEP || 14))
mkdirSync(OUT, { recursive: true })
const d = new Date()
const p2 = (n) => String(n).padStart(2, '0')
const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`
const file = join(OUT, `hq-${ts}.dump`)
// -Fc = custom, compressed, restorable with pg_restore. --no-owner keeps it portable.
try {
execFileSync(pgDump, ['-Fc', '--no-owner', '-f', file, url], { stdio: ['ignore', 'inherit', 'inherit'] })
} catch (e) {
console.error(`pg_dump failed: ${e instanceof Error ? e.message : String(e)}`)
process.exit(1)
}
const kb = (statSync(file).size / 1024).toFixed(1)
console.log(`✓ backup written: ${file} (${kb} KB)`)
// Rotation: keep the newest KEEP hq-YYYYMMDD-HHMMSS.dump files.
const dumps = readdirSync(OUT).filter((f) => /^hq-\d{8}-\d{6}\.dump$/.test(f)).sort()
for (const f of dumps.slice(0, Math.max(0, dumps.length - KEEP))) {
unlinkSync(join(OUT, f))
console.log(` rotated out: ${f}`)
}
console.log(`retained ${Math.min(dumps.length, KEEP)} backup(s) in ${OUT} (HQ_BACKUP_KEEP=${KEEP})`)
Loading…
Cancel
Save