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 run typecheck` (root + workspaces) | clean, no errors |
| `npm test` (`vitest run`) | **528 tests pass across 97 files** (5 Postgres-gated skipped) | | `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, 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 renewals, cockpit, audit viewer, SMS gateway, module directory/portals/search, and
the D31/D32 plaintext-credential change with its own migration + review). Test files 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 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 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 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. 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.) */
.hq-content { overflow-x: hidden; } .hq-content { overflow-x: hidden; }
.wf-table-wrap { overflow-x: auto; }
/* Relationship-pulse ribbon (Client 360 overview). */ /* Relationship-pulse ribbon (Client 360 overview). */
.pulse { .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.done .nm { color: var(--text-dim); }
.cdr-node.now .nm { font-weight: 600; } .cdr-node.now .nm { font-weight: 600; }
.cdr-node .dt { font-size: 10px; color: var(--text-dim); font-family: var(--mono); margin-top: 3px; } .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 /* linked payment/document reference under a done step's date */
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. */
.cdr-node .ref { .cdr-node .ref {
font-size: 10px; font-family: var(--mono); color: var(--accent); background: none; border: none; font-size: 10px; font-family: var(--mono); color: var(--accent); background: none; border: none;
padding: 0; margin-top: 3px; cursor: pointer; max-width: 104px; padding: 0; margin-top: 3px; cursor: pointer; max-width: 104px; overflow: hidden;
white-space: normal; overflow-wrap: anywhere; line-height: 1.3; text-align: center; text-overflow: ellipsis; white-space: nowrap;
} }
.cdr-node .ref:hover { text-decoration: underline; } .cdr-node .ref:hover { text-decoration: underline; }
/* outstanding panel */ /* outstanding panel */

@ -52,9 +52,6 @@ export function ClientDetail() {
const id = useParams()['id'] ?? '' const id = useParams()['id'] ?? ''
const nav = useNavigate() const nav = useNavigate()
const [sp, setSp] = useSearchParams() 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 client = useData(() => getClient(id), [id])
const modules = useData(getModules, []) const modules = useData(getModules, [])
const cms = useData(() => getClientModules(id), [id]) const cms = useData(() => getClientModules(id), [id])
@ -69,9 +66,7 @@ export function ClientDetail() {
const [ticketPage, setTicketPage] = useState(1) const [ticketPage, setTicketPage] = useState(1)
const tickets = useData(() => getTickets({ clientId: id, page: ticketPage, pageSize: 50 }), [id, ticketPage]) 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. // 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 — const ticketTypes = useData(getTicketTypes, [])
// 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 ticketTypeOptions = ticketTypes.data ?? TICKET_TYPE_FALLBACK const ticketTypeOptions = ticketTypes.data ?? TICKET_TYPE_FALLBACK
// Undefined = closed. `moduleCode` pre-fills the module when the ticket is raised from // 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. // 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 [linkingDoc, setLinkingDoc] = useState(false)
const [docLinkError, setDocLinkError] = useState<string | undefined>() 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 const c = client.data
if (client.error !== undefined) return <ErrorState message={client.error} onRetry={client.reload} /> if (client.error !== undefined) return <ErrorState message={client.error} onRetry={client.reload} />
if (c === undefined) return <div className="wf-page"><Skeleton rows={6} /></div> if (c === undefined) return <div className="wf-page"><Skeleton rows={6} /></div>
@ -346,7 +345,6 @@ export function ClientDetail() {
name={moduleName(cm.moduleId)} name={moduleName(cm.moduleId)}
refreshToken={milestonesVersion} refreshToken={milestonesVersion}
payments={ledger.data?.payments ?? []} payments={ledger.data?.payments ?? []}
docs={ledger.data?.documents ?? []}
onTickPayment={(key, label) => { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }} onTickPayment={(key, label) => { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }}
onTickCreds={(key, label) => { setCredsError(undefined); setCredsDialog({ cm, key, label }) }} onTickCreds={(key, label) => { setCredsError(undefined); setCredsDialog({ cm, key, label }) }}
onTickDocument={(key, label) => { setDocLinkError(undefined); setDocPicker({ clientModuleId: cm.id, 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). * 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 * A done step carrying `paymentId`/`documentId` also renders a small reference under its
* date (`onOpenDocument` / `onOpenPayments`) otherwise a linked step looks identical to a * date (`onOpenDocument` / `onOpenPayments`) otherwise a linked step looks identical to a
* bare-toggled one. That reference is resolved against the client's ledger (`payments` / * bare-toggled one.
* `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.
* "+ Add step" appends a project-specific milestone. Every change is one audited POST * "+ 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 * 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 * 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: { function StatusLine(props: {
clientModuleId: string; name: string; refreshToken: number 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[] payments: Payment[]
docs: Doc[]
onTickPayment: (key: string, label: string) => void onTickPayment: (key: string, label: string) => void
onTickCreds: (key: string, label: string) => void onTickCreds: (key: string, label: string) => void
onTickDocument: (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="nm">{m.label}</span>
<span className="dt">{m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}</span> <span className="dt">{m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}</span>
{/* The linked payment/document itself otherwise a done payment/quotation {/* The linked payment/document itself otherwise a done payment/quotation
step shows only a date, with no evidence the link actually landed. The step shows only a date, with no evidence the link actually landed. */}
reference wraps inside the node (it used to be ellipsised to one 104px {m.done && m.documentId !== null && (
line, which cut the amount the one thing it exists to show), and the <button
tooltip/accessible name carries the full detail rather than static text. */} type="button" className="ref" title="Open the linked document"
{m.done && m.documentId !== null && (() => { onClick={() => props.onOpenDocument(m.documentId!)}
const d = props.docs.find((x) => x.id === m.documentId) >
const detail = d === undefined view document
? 'Linked document — open it' </button>
: `${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>
)
})()}
{m.done && m.paymentId !== null && (() => { {m.done && m.paymentId !== null && (() => {
const p = props.payments.find((x) => x.id === m.paymentId) 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 ( return (
<button <button
type="button" className="ref" title={detail} aria-label={detail} type="button" className="ref" title="The linked payment — open Payments & plans"
onClick={() => props.onOpenPayments()} onClick={() => props.onOpenPayments()}
> >
{p !== undefined ? `${p.receivedOn} · ${inr(p.amountPaise)}` : 'payment linked'} {p !== undefined ? `${p.receivedOn} · ${inr(p.amountPaise)}` : 'payment linked'}

@ -70,27 +70,13 @@ export function Dashboard() {
</div> </div>
</section> </section>
{/* SMS low-balance alert (D28) surfaced here so it's seen without opening the SMS {/* SMS low-balance alert (D28) — surfaced here so it's seen without opening the SMS screen. */}
screen. Three states, never collapsed into one: while the check is in flight a {smsLow.data !== undefined && smsLow.data.lowCount > 0 && (
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 ? (
<Notice tone="warn"> <Notice tone="warn">
<Link to="/sms-balances" style={{ color: 'inherit', fontWeight: 600 }}> <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 {smsLow.data.lowCount} SMS client{smsLow.data.lowCount === 1 ? '' : 's'} low on balance review &amp; top up
</Link> </Link>
</Notice> </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). */} {/* KPIs — the app's existing stat cards (kept minimal; red only when a send failed). */}
@ -160,15 +146,11 @@ export function Dashboard() {
</Card> </Card>
<Card <Card
title="Onboarding stalled" count={stalled.isLoading ? undefined : (stalled.data?.length ?? 0)} title="Onboarding stalled" count={stalled.data?.length ?? 0}
empty="Nothing stalled." empty="Nothing stalled."
// Still fetching (data undefined, no error yet) is not the same as a genuine // A fetch failure is not the same as a genuine empty result — show the error
// empty result — show the usual loading placeholder, not "Nothing stalled.", // (with retry) instead of quietly reporting "Nothing stalled." on failure.
// until the request actually resolves one way or the other. isEmpty={stalled.error === undefined && (stalled.data ?? []).length === 0}
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}
> >
{stalled.error !== undefined {stalled.error !== undefined
? <ErrorState message={stalled.error} onRetry={stalled.reload} /> ? <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 /** 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 * scrollable list, or an empty-state line when there's nothing to show. */
* 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. */
function Card(props: { function Card(props: {
title: string; count?: number; link?: { to: string; label?: string } title: string; count?: number; link?: { to: string; label?: string }
empty: string; isEmpty: boolean; loading?: boolean; children: ReactNode empty: string; isEmpty: boolean; children: ReactNode
}) { }) {
return ( return (
<section className="dash-card"> <section className="dash-card">
@ -214,9 +194,7 @@ function Card(props: {
{props.count !== undefined && <span className="count">{props.count}</span>} {props.count !== undefined && <span className="count">{props.count}</span>}
{props.link !== undefined && <Link to={props.link.to}>{props.link.label ?? 'All'} </Link>} {props.link !== undefined && <Link to={props.link.to}>{props.link.label ?? 'All'} </Link>}
</div> </div>
{props.loading === true {props.isEmpty ? <div className="dash-empty">{props.empty}</div> : <div className="dash-list">{props.children}</div>}
? <div className="dash-list"><Skeleton rows={2} /></div>
: props.isEmpty ? <div className="dash-empty">{props.empty}</div> : <div className="dash-list">{props.children}</div>}
</section> </section>
) )
} }

@ -4,8 +4,6 @@
// skips a client whose name already contains that number, and only touches found=true rows. // 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. // 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 // 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 { readFileSync } from 'node:fs'
import { openDb, sqliteFilePath } from '../src/db' import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg' 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 // 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. // 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 // 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 { readFileSync } from 'node:fs'
import { openDb, sqliteFilePath } from '../src/db' import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg' 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 // 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 // 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. // 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 // Run from repo root:
// DATABASE_URL prefix — followed literally on the production host it resolves to SQLite, not // npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts [--force]
// 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)
import { openDb, sqliteFilePath } from '../src/db' import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg' import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' 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. // and must_change_password=1 (they change it on first login). Active per ACTIVE_STAT.
// Idempotent: skips a username/email that already exists. // Idempotent: skips a username/email that already exists.
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/import-employees.ts // 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 { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg' import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { createEmployee } from '../src/repos-employees' import { createEmployee } from '../src/repos-employees'

@ -3,8 +3,6 @@
// checklist onto their new per-module template, carrying mapped ticks. Idempotent — safe // checklist onto their new per-module template, carrying mapped ticks. Idempotent — safe
// to re-run (a second run does nothing). // to re-run (a second run does nothing).
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/migrate-onboarding-templates.ts // 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 { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg' import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' 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) days = await getNumberSetting(db, 'project.stall_days', 30)
} }
const today = new Date().toISOString().slice(0, 10) const today = new Date().toISOString().slice(0, 10)
// FIX 4 (no silent caps): unknownAgeExcluded surfaces the count of pending projects the res.json({ ok: true, projects: await stalledProjects(db, days, today) })
// 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 })
} catch (err) { } catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(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 { AsyncLocalStorage } from 'node:async_hooks'
import fs from 'node:fs'
import pg from 'pg' import pg from 'pg'
import { sqliteFilePath, type DB } from './db' import type { DB } from './db'
import { PG_MIGRATIONS } from './migrations-pg' 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 * Same resolution, but hard-fails instead of silently falling back to SQLite when running in
* database for one-off deploy/maintenance scripts, where creating a brand-new empty DB is * production for one-off deploy scripts, where a throwaway SQLite write is never the right
* never the right outcome (unlike the server, which legitimately creates one on first boot in * outcome (unlike the server, which legitimately runs SQLite in dev). `hardcoded` is
* dev this function is never called from server.ts). * injectable so tests can simulate the hardcoded URL being accidentally emptied out.
*
* 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.
*/ */
export function requireDbUrlForEnv( export function requireDbUrlForEnv(env: NodeJS.ProcessEnv = process.env, hardcoded = HARDCODED_DATABASE_URL): string {
env: NodeJS.ProcessEnv = process.env,
hardcoded = HARDCODED_DATABASE_URL,
sqliteExists: (file: string) => boolean = fs.existsSync,
argv: readonly string[] = process.argv,
): string {
const pgUrl = resolveDbUrl(env, hardcoded) const pgUrl = resolveDbUrl(env, hardcoded)
if (pgUrl === '') { if (pgUrl === '' && env['NODE_ENV'] === 'production') {
const file = sqliteFilePath(env['HQ_DATA_DIR']) throw new Error(
const optedIn = '[db] refusing to run: NODE_ENV=production but no Postgres target resolved ' +
file === ':memory:' || '(DATABASE_URL is unset and the hardcoded production URL is empty) — this would ' +
(env['HQ_DB_TARGET'] ?? '').trim().toLowerCase() === 'sqlite' || 'otherwise silently create a throwaway SQLite database instead of touching production data.',
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.',
)
}
} }
return pgUrl return pgUrl
} }

@ -534,15 +534,6 @@ function migrate(db: SqliteRaw): void {
if (!cmCols.some((c) => c.name === 'created_at')) { if (!cmCols.some((c) => c.name === 'created_at')) {
db.exec(`ALTER TABLE client_module ADD COLUMN created_at TEXT`) 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 }[] const modCols2 = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[]
if (!modCols2.some((c) => c.name === 'field_spec')) { if (!modCols2.some((c) => c.name === 'field_spec')) {
db.exec(`ALTER TABLE module ADD COLUMN field_spec TEXT NOT NULL DEFAULT '[]'`) db.exec(`ALTER TABLE module ADD COLUMN field_spec TEXT NOT NULL DEFAULT '[]'`)

@ -1138,15 +1138,12 @@ export async function commitApexFull(
) )
if (existingCm === undefined) { if (existingCm === undefined) {
await db.run( 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, `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) installed_on, provider, username, password_enc, details, remark, field_values)
VALUES (?, ?, ?, ?, 'yearly', 'standard', 1, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, 'yearly', 'standard', 1, ?, ?, ?, ?, ?, ?, ?)`,
a.id, a.clientId, moduleId, a.status, a.installedOn, a.id, a.clientId, moduleId, a.status, a.installedOn,
a.provider, a.username, a.passwordEnc, JSON.stringify(a.details), a.remark, 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) { } else if (a.serviceTouched) {
// Client pre-dated this import with a live assignment: service data wins. // 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. // Build the carried tick-state keyed by NEW key, and set aside ad-hoc custom rows.
const carriedState = new Map<string, OldRow>() const carriedState = new Map<string, OldRow>()
const customRows: OldRow[] = [] const customRows: OldRow[] = []
let alreadyNew = true
for (const r of oldRows) { for (const r of oldRows) {
const isAdHocCustom = !templateKeys.has(r.key) && KEY_MAP[r.key] === undefined && !KNOWN_LEGACY_KEYS.has(r.key) if (!templateKeys.has(r.key)) alreadyNew = false
if (isAdHocCustom) { customRows.push(r); continue } 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] const target = templateKeys.has(r.key) ? r.key : KEY_MAP[r.key]
if (target !== undefined && templateKeys.has(target)) { if (target !== undefined && templateKeys.has(target)) {
if (r.done === 1) { if (r.done === 1) {
@ -97,17 +99,10 @@ export async function migrateOnboardingTemplates(
dropped++ dropped++
} }
} }
// Idempotency: if the project's keys already ARE exactly the template, skip. Computed // Idempotency: if the project's keys already ARE exactly the template, skip.
// over NON-CUSTOM rows only (regression fix): the old check flagged `alreadyNew = false` const oldKeySet = new Set(oldRows.map((r) => r.key))
// for ANY row whose key isn't a template key — including deliberately-preserved `custom_*` const identical = alreadyNew && oldKeySet.size === templateKeys.size
// rows (the `addProjectMilestone` "+ Add step" key pattern) — so a project with even one && [...templateKeys].every((k) => oldKeySet.has(k))
// 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))
if (identical) continue if (identical) continue
projects++ projects++
await db.transaction(async () => { await db.transaction(async () => {

@ -373,12 +373,4 @@ END;
id: '018-client-module-created-at', id: '018-client-module-created-at',
sql: `ALTER TABLE client_module ADD COLUMN IF NOT EXISTS created_at text;`, 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 * Also replaces the removed UI date-writers (Client Detail redesign dropped `RowDate` /
* caller's transaction and audited together: * 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
* 1. **Status**, advanced to the step's target never downgraded. * triggering milestone's own done_on never overwriting a date already on file (e.g. one
* 2. **installed_on / trained_on**, stamped from the triggering milestone's own done_on. * set by the APEX importer or a direct edit).
* 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.
*/ */
async function applyStepEffects( async function advanceStatusForStep(
db: DB, userId: string, clientModuleId: string, key: string, doneOn: string | null, db: DB, userId: string, clientModuleId: string, key: string, doneOn: string | null,
): Promise<void> { ): Promise<void> {
const target = STEP_STATUS[key] 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 }>( 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) `SELECT status, installed_on, trained_on FROM client_module WHERE id=?`, clientModuleId)
if (cm === undefined) return if (cm === undefined) return
const sets: string[] = [] if ((STATUS_RANK[cm.status] ?? 0) >= (STATUS_RANK[target] ?? 0)) return // never downgrade
const args: unknown[] = [] const sets = ['status=?']
const before: Record<string, unknown> = {} const args: unknown[] = [target]
const after: Record<string, unknown> = {} const before: Record<string, unknown> = { status: cm.status }
if ((STATUS_RANK[cm.status] ?? 0) < (STATUS_RANK[target] ?? 0)) { // never downgrade const after: Record<string, unknown> = { status: target }
sets.push('status=?'); args.push(target)
before['status'] = cm.status; after['status'] = target
}
if (target === 'installed' && cm.installed_on === null && doneOn !== null) { if (target === 'installed' && cm.installed_on === null && doneOn !== null) {
sets.push('installed_on=?'); args.push(doneOn) sets.push('installed_on=?'); args.push(doneOn)
before['installedOn'] = null; after['installedOn'] = doneOn before['installedOn'] = null; after['installedOn'] = doneOn
@ -87,7 +72,6 @@ async function applyStepEffects(
sets.push('trained_on=?'); args.push(doneOn) sets.push('trained_on=?'); args.push(doneOn)
before['trainedOn'] = null; after['trainedOn'] = doneOn before['trainedOn'] = null; after['trainedOn'] = doneOn
} }
if (sets.length === 0) return
args.push(clientModuleId) args.push(clientModuleId)
await db.run(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`, ...args) await db.run(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`, ...args)
await writeAudit(db, userId, 'update', 'client_module', clientModuleId, before, after) 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. * 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 * 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. * 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( export async function setMilestone(
db: DB, userId: string, clientModuleId: string, key: string, done: boolean, 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') throw new Error('doneOn must be YYYY-MM-DD')
} }
let stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null 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". let linkedPayment: string | null = null
const wasDone = before.done === 1
let linkedPayment: string | null = paymentId === undefined && wasDone ? before.payment_id : null
if (done && paymentId !== undefined && paymentId !== '') { if (done && paymentId !== undefined && paymentId !== '') {
const pay = await db.get<{ received_on: string }>( 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=?)`, `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 linkedPayment = paymentId
if (doneOn === undefined) stamp = pay.received_on // link date wins unless caller forced one 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 !== '') { if (done && documentId !== undefined && documentId !== '') {
const doc = await db.get<{ id: string }>( const doc = await db.get<{ id: string }>(
`SELECT id FROM document WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`, `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') if (doc === undefined) throw new Error('Document not found for this client')
linkedDocument = documentId linkedDocument = documentId
} }
// Unticking always clears both links, whatever was resolved above.
const finalPayment = done ? linkedPayment : null
const finalDocument = done ? linkedDocument : null
await db.run( await db.run(
`UPDATE project_milestone SET done=?, done_on=?, payment_id=?, document_id=? `UPDATE project_milestone SET done=?, done_on=?, payment_id=?, document_id=?
WHERE client_module_id=? AND key=?`, 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, await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined,
{ key, done, doneOn: stamp, paymentId: finalPayment, documentId: finalDocument }) { key, done, doneOn: stamp, paymentId: linkedPayment, documentId: linkedDocument })
if (done) await applyStepEffects(db, userId, clientModuleId, key, stamp) if (done) await advanceStatusForStep(db, userId, clientModuleId, key, stamp)
return listProjectMilestones(db, clientModuleId) return listProjectMilestones(db, clientModuleId)
}) })
} }
@ -367,13 +339,9 @@ export async function addProjectMilestone(
* single summary audit row. Only touches projects that actually have the milestone. * single summary audit row. Only touches projects that actually have the milestone.
* *
* FIX F: routes each project through `setMilestone` the SAME status-advance and * 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) * 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. * 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( export async function bulkSetMilestone(
db: DB, userId: string, clientModuleIds: string[], key: string, done: boolean, doneOn?: string, 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 * 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 * `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 * 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 * 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 * 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" * guessed at the intent is "parked past N days", not "we don't know, so assume forever".
* and counted in `unknownAgeExcluded` (FIX 4) so that exclusion is never silent.
* Non-aggregated columns are wrapped in MIN() grouping by `pm.client_module_id` alone isn't * 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). * enough for Postgres to infer they're single-valued per group (D15 portability).
*/ */
export async function stalledProjects( export async function stalledProjects(db: DB, olderThanDays: number, today: string): Promise<PendingProject[]> {
db: DB, olderThanDays: number, today: string, const rows = await db.all<PendingProject & { last_done: string | null; created_at: string | null }>(
): Promise<StalledProjectsResult> {
const allRows = await db.all<PendingProject & { last_done: string | null; created_at: string | null }>(
`SELECT pm.client_module_id AS "clientModuleId", MIN(cm.client_id) AS "clientId", `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", 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 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`, 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 cutoff = new Date(Date.parse(today) - olderThanDays * 86_400_000).toISOString().slice(0, 10)
const rows: PendingProject[] = [] return rows
let unknownAgeExcluded = 0 .filter((r) => {
for (const r of allRows) { if (r.last_done !== null) return r.last_done < cutoff
const { last_done, created_at, ...rest } = r if (r.created_at !== null) return r.created_at.slice(0, 10) < cutoff
if (last_done !== null) { return false
if (last_done < cutoff) rows.push(rest) })
continue .map(({ last_done: _last_done, created_at: _created_at, ...rest }) => rest)
}
if (created_at !== null) {
if (created_at.slice(0, 10) < cutoff) rows.push(rest)
continue
}
unknownAgeExcluded++
}
return { rows, unknownAgeExcluded }
} }

@ -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 // 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 // production box — where DATABASE_URL is unset — each script silently opened a brand-new
// empty SQLite file instead of the real Postgres. // 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 { describe, it, expect } from 'vitest'
import { pgTargetDescription, requireDbUrlForEnv, resolveDbUrl } from '../src/db-pg' 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)', () => { describe('resolveDbUrl (D19 engine selection, the one shared resolver)', () => {
it('DATABASE_URL wins regardless of NODE_ENV', () => { 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)', () => { describe('requireDbUrlForEnv (deploy-script guard — hard-fail instead of a throwaway SQLite write)', () => {
it('behaves exactly like resolveDbUrl when a Postgres target is available (no SQLite file check runs at all)', () => { it('behaves exactly like resolveDbUrl when a Postgres target is available', () => {
expect(requireDbUrlForEnv({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y') expect(requireDbUrlForEnv({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
expect(requireDbUrlForEnv({ NODE_ENV: 'production' }, HARDCODED)).toBe(HARDCODED) 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)', () => { it('hard-fails instead of silently returning SQLite when NODE_ENV=production resolves nothing', () => {
expect(requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, () => true)).toBe('') // Simulates the hardcoded URL having been accidentally emptied out.
// Even NODE_ENV=production is fine as long as the SQLite file it would land on already exists. expect(() => requireDbUrlForEnv({ NODE_ENV: 'production' }, '')).toThrow(/refusing to run/i)
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('')
}) })
}) })

@ -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 { describe, it, expect } from 'vitest'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { writeAudit, listAudit } from '../src/audit' 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', () => { describe('hq db', () => {
it('creates every HQ-1 table', async () => { it('creates every HQ-1 table', async () => {
@ -26,55 +20,4 @@ describe('hq db', () => {
expect(rows[0]!.action).toBe('update') expect(rows[0]!.action).toBe('update')
expect(JSON.parse(rows[0]!.after_json!)).toEqual({ name: 'Acme Ltd' }) 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<{ db.get<{
status: string; kind: string; provider: string | null; username: string | null status: string; kind: string; provider: string | null; username: string | null
password_enc: string | null; details: string; remark: string | null; installed_on: 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, `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 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) 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'))! const atm = (await cmFor(db, '103', 'ATM'))!
expect(atm.status).toBe('live') expect(atm.status).toBe('live')
expect(atm.provider).toBe('EWIRE') 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'))! const mob = (await cmFor(db, '103', 'MOBILEAPP'))!
expect(JSON.parse(mob.field_values)).toEqual({ contact: '8301932166' }) // D21 declared field expect(JSON.parse(mob.field_values)).toEqual({ contact: '8301932166' }) // D21 declared field
expect(JSON.parse(mob.details)).toEqual([]) expect(JSON.parse(mob.details)).toEqual([])

@ -313,48 +313,6 @@ describe('onboarding template migration', () => {
expect(audits[0]!.after_json).toContain('custom_ef56gh78') 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 () => { it('a known old-generic key with no forward mapping (amc_start) is still dropped, not treated as custom', async () => {
const cmId = await makeSmsClientModule() const cmId = await makeSmsClientModule()
await seedOldGenericRows(cmId) // includes ticked 'installed'/'go_live' and un-ticked 'amc_start' 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 // 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 // (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 // 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 { base, staffH } = await httpWorld()
const res = await fetch(`${base}/projects/stalled`, { headers: staffH }) const res = await fetch(`${base}/projects/stalled`, { headers: staffH })
expect(res.status).toBe(200) 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(body.ok).toBe(true)
expect(Array.isArray(body.projects)).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 () => { 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') 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. // last done_on = 2026-03-01, today = 2026-07-19 → ~140 days parked.
const stalled = await stalledProjects(db, 30, '2026-07-19') 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)
const row = stalled.rows.find((p) => p.clientModuleId === cm.id)! const row = stalled.find((p) => p.clientModuleId === cm.id)!
expect(row.clientName).toBe('Kothavara SCB') expect(row.clientName).toBe('Kothavara SCB')
expect(row.moduleCode).toBe('MISC') expect(row.moduleCode).toBe('MISC')
expect(stalled.unknownAgeExcluded).toBe(0)
}) })
it('excludes a recently-progressed project', async () => { 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, 'advance_paid', true, '2026-03-01')
await setMilestone(db, 'u1', cm.id, 'setup_done', 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') const stalled = await stalledProjects(db, 365, '2026-07-19')
expect(stalled.rows).toHaveLength(0) expect(stalled).toHaveLength(0)
expect(stalled.unknownAgeExcluded).toBe(0)
}) })
it('excludes projects with no pending steps (fully ticked) and inactive/live projects', async () => { 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') await setMilestone(db, 'u1', done.id, key, true, '2026-01-01')
} }
const stalled = await stalledProjects(db, 1, '2026-07-19') 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. // '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') 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. // 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) 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') const stalled = await stalledProjects(db, 30, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id) expect(stalled.map((p) => p.clientModuleId)).not.toContain(cm.id)
expect(stalled.unknownAgeExcluded).toBe(0)
}) })
it('a project created long ago with zero ticks IS stalled', async () => { 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' }) 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) 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') 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 () => { 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. // doesn't stamp it) — created_at is genuinely unknown.
await db.run(`UPDATE client_module SET created_at=NULL WHERE id=?`, cm.id) 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 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) expect(stalled.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)
}) })
it('assignModule itself stamps a real (non-null) created_at', async () => { 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.) (Superseded by D31/D32: the key was removed; the pull now runs keyless.)
## D33 — Database backups (ops, 2026-07-19) ## D33 — Database backups (ops, 2026-07-19)
`apps/hq/scripts/backup.ts``apps/hq/src/backup.ts` (`npm run backup`) `pg_dump`s the `scripts/backup.mjs` (`npm run backup`) `pg_dump`s the database to a timestamped, compressed,
database to a timestamped, compressed,
`pg_restore`-able file under `./backups` (gitignored), keeping the newest `HQ_BACKUP_KEEP` `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` (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>`. 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 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 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 (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) ## D34 — Employee master import + invited/first-login flag (2026-07-19)
Imported the 11-row employee master (`empmaster.xlsx`) into `staff_user` via Imported the 11-row employee master (`empmaster.xlsx`) into `staff_user` via

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

@ -194,22 +194,6 @@ input.wf:focus, select.wf:focus, textarea.wf:focus {
.wf-toolbar .spacer { flex: 1; } .wf-toolbar .spacer { flex: 1; }
/* ================= tables ================= */ /* ================= 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 { table.wf {
border-collapse: separate; border-spacing: 0; width: 100%; border-collapse: separate; border-spacing: 0; width: 100%;
background: var(--bg-raised); border: 1px solid var(--border); 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