Compare commits

..

14 Commits

Author SHA1 Message Date
Thomas Joise 551c9e3d30 merge: readable linked payment/document chip + SMS alert loading/error state 1 day ago
Thomas Joise a267cd07e6 merge: readable linked payment/document chip + SMS alert loading/error state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

# Conflicts:
#	apps/hq-web/src/pages/Dashboard.tsx
1 day ago
Thomas Joise 23b0b64de2 merge: script DATABASE_URL docs + SQLite opt-in, always-stamp install/train dates, keep links on re-tick, backup resolves prod DB
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 0adce5844b fix(backup): resolve the production database the way the server does
`npm run backup` ran scripts/backup.mjs, which read process.env.DATABASE_URL
directly and exited when it was unset. On the production box DATABASE_URL is NOT
exported -- the connection is resolved (D19) -- so the backup command could never
reach the real data.

The dump now lives in apps/hq (src/backup.ts + a thin scripts/backup.ts CLI, the
same split the other maintenance scripts use, so it is typechecked and unit
tested) and resolves its target through the shared resolveDbUrl. It logs the
resolved host/db -- never the password -- before pg_dump runs, and a SQLite
outcome is a loud error naming backup-hq.sh instead of a dump of nothing.
Behaviour otherwise unchanged: -Fc dump under ./backups (or HQ_BACKUP_DIR),
rotation to the newest HQ_BACKUP_KEEP (default 14), same pg_dump discovery.

The .mjs entry could not import the TS resolver, and duplicating the resolution
would have re-introduced the copy of the production connection string that
6f50799 deliberately removed -- hence the move to TS. `npm run backup` is
unchanged for the operator; prod already runs `npx tsx apps/hq/scripts/...` for
maintenance (see DEPLOY-HQ.md), so tsx is on the box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 4c1514c315 fix(milestones): stamp installed_on/trained_on on every tick, keep links on a re-tick
Two bugs in the same tick path.

1. installed_on / trained_on were written only as a side effect of a status
   UPGRADE. The ~300 APEX-imported projects are already at 'live' -- the furthest
   status -- so ticking Installation or Training upgraded nothing and therefore
   recorded nothing, and the UI's own date writers were removed in the Client
   Detail redesign, leaving those columns unfillable and the Module Directory's
   "Installed" column blank forever. The date is now stamped whenever the step is
   ticked, independent of any status move: same transaction, same audit payload,
   still write-once (an existing date is never overwritten) and still no downgrade.
   A tick that changes neither status nor a date now writes nothing at all.

2. setMilestone wrote payment_id/document_id as `done ? linked : null`, so any tick
   that did not resend the ids cleared them. bulkSetMilestone routes through it and
   never passes ids (one key, many projects), so a single bulk re-tick silently cut
   every project's step loose from the payment or quotation it had been linked to.
   An already-done step now keeps the link it carries when nothing is supplied.
   An explicit empty id is still an unlink, and unticking still clears both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 77f135f0e2 fix(scripts): document the DATABASE_URL-prefixed invocation, make SQLite an explicit opt-in
apply-onboarding-pipeline.ts documented its own command with no `DATABASE_URL=`
prefix -- the only hardened data script missing it. Followed literally on the
production host it resolves to SQLite, not the real database. Header corrected to
match its siblings.

The deeper problem is that landing on SQLite was a DEFAULT, not a decision: with no
DATABASE_URL, resolution quietly returned '' and the run was only saved by the
"never CREATE a database" guard -- which passes as soon as any stale ./data/hq.db
exists under the operator shell's cwd. requireDbUrlForEnv now refuses a SQLite
outcome unless it was explicitly asked for (HQ_DB_TARGET=sqlite, a --sqlite flag,
or HQ_DATA_DIR=:memory:), and the error names both escape hatches. The create guard
is unchanged and still applies after the opt-in. server.ts calls resolveDbUrl
directly, so first-boot DB creation is untouched.

All five maintenance-script headers now state the SQLite opt-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 367fc0324e fix(dashboard): never report "all good" when the fetch failed
The SMS low-balance alert rendered only when the request succeeded with
lowCount > 0, so a FAILED check looked exactly like "no client is low" --
the dangerous direction. It now has three distinct states: a skeleton
while the check is in flight, an ErrorState with retry when it fails, and
a quiet positive caption only for a loaded, genuinely-empty result (which
also spells out `unknownCount`, since balances the gateway could not
return are not "fine" either).

Same three-state audit on the rest of the page: the "Onboarding stalled"
tile had the error case fixed already but still showed count 0 and
"Nothing stalled." while its fetch was in flight -- it now takes a
`loading` prop (new on Card) and shows a skeleton, and its count appears
only once the list has loaded. Every other bento card is fed by the one
dashboard payload, whose loading/error/empty states are already
distinguished at page level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise b32f7c32c8 fix(client-detail): make the linked payment/document reference legible
The status-line reference under a done step was `white-space: nowrap` +
`overflow: hidden` inside a 104px node, so the linked payment's amount --
the only reason the chip exists -- was ellipsised away, and the `title`
that could have revealed it held static text instead.

- the reference now wraps (up to two short lines) rather than clipping;
  it grows the node downwards only, so the track keeps its 112px columns
  and its horizontal scroll on narrow screens is unchanged
- `title` + `aria-label` now carry the real detail: payment date, amount
  and reference / document type, number, date and payable
- the document chip (quotation_sent), which clipped the same way, is
  resolved against the ledger too and shows the document number instead
  of the static "view document ->"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 1717900e02 merge: restore sticky table headers (bounded 70vh wrapper), dashboard loading state, ticket-type over-fetch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise aa541270d6 fix(onboarding): APEX-imported projects were invisible on the stalled tile
client_module.created_at was added and stamped in assignModule, and
stalledProjects now correctly excludes no-tick projects whose created_at
is NULL — but the APEX importer (the path that created the real ~300
clients' projects) inserted client_module WITHOUT created_at. Every
imported project with zero ticks was therefore permanently invisible on
the "Onboarding stalled" tile, exactly the population it exists for.

Three parts:
- Stamp created_at in the APEX importer's client_module insert.
- Backfill existing NULL rows from installed_on where known (both
  db.ts migrate() for SQLite and migrations-pg.ts for Postgres); never
  fabricate a date when installed_on is also unknown.
- stalledProjects now returns { rows, unknownAgeExcluded } instead of a
  bare array, so projects excluded for unprovable age are counted, not
  silently dropped. GET /projects/stalled responds with
  { ok, projects, unknownAgeExcluded }; UI wiring left to the parallel
  agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 4de152a7f4 fix(ui): restore sticky table headers, dashboard loading flash, ticket-type over-fetch
- packages/ui/src/tokens.css: bound .wf-table-wrap to a viewport-derived max-height
  and matching overflow-y so it's a real scroll container again. overflow-x: auto
  alone left overflow-y at its computed "auto" (per the CSS overflow spec, a
  visible/non-visible axis pairing promotes visible to auto) with no height bound,
  so its scrollTop was permanently 0 and table.wf th's `position: sticky` never had
  anything to stick against — the header just scrolled away with the page on every
  table screen. Chose the bounded-wrapper fix (over deleting the dead sticky rule)
  since it keeps the header pinned as advertised; short tables render unchanged,
  long ones scroll inside the box exactly like the existing .dash-list pattern.
  Also moved the .wf-table-wrap rule out of apps/hq-web/src/app.css into tokens.css
  next to the other table.wf rules, since it's part of the @sims/ui component.
- apps/hq-web/src/pages/Dashboard.tsx: the "Onboarding stalled" card treated
  "still loading" the same as "genuinely empty", flashing "Nothing stalled."
  before the fetch resolved. Card now takes a loading prop and shows the usual
  skeleton placeholder while pending; the positive empty copy and error state
  are unchanged.
- apps/hq-web/src/pages/ClientDetail.tsx: getTicketTypes was fetched unconditionally
  on every Client 360 load for the tickets tab's Type column. Gated it behind the
  tickets tab being active (moved the tab computation earlier so it's available to
  gate the loader); NewTicketDialog's own getTicketTypes call for the new-ticket
  pre-fill is untouched.
- STATUS.md: prose under the verification table still said "The 416 figure..." while
  the table itself says 528 — updated the prose to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 591d0e9b59 fix(onboarding-migration): idempotency check must ignore ad-hoc custom_* rows
alreadyNew was set false for EVERY row whose key wasn't a template key,
including custom_* rows that the same function deliberately preserves
verbatim. So a project with even one ad-hoc "+ Add step" entry was
never "identical" and got DELETE-and-rebuilt on every single run (new
row ids, a fresh audit row each time) — contradicting the documented
"safe to re-run / a second run does nothing".

Idempotency is now computed over non-custom rows only, using the same
custom_* discriminator addProjectMilestone's key generation relies on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise ee0367dfb5 fix(db-pg): make requireDbUrlForEnv guard outcome-based, not NODE_ENV-gated
The guard only threw when NODE_ENV==='production' AND resolution came
back empty. But NODE_ENV is exactly what isn't reliably set for the
maintenance scripts this guards (they're run by hand from an operator
shell with no service-definition env and no dotenv), so on the real
prod box the guard never fired: resolution silently returned '', the
script opened a brand-new empty SQLite file under <cwd>/data, and
printed a false "0 project(s) rebuilt" success.

Now the guard fires on the OUTCOME instead: whenever resolution
selects SQLite, refuse to run unless the target file already exists —
a migration/maintenance script must never CREATE a database. Applies
only to requireDbUrlForEnv (the script entry path); server.ts calls
resolveDbUrl directly and is untouched, so first boot still creates
its DB as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 6f50799d3c test(db-resolve): remove duplicated production password from test file
The test never connects to Postgres, it only asserts resolution
behaviour, so the real prod URL had no reason to be duplicated here.
Swapped for a dummy postgres://u:p@h:5432/db throughout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago

@ -0,0 +1,179 @@
{
"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"
}

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

@ -0,0 +1,16 @@
{
"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"
}

@ -0,0 +1,17 @@
{
"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"
}

@ -0,0 +1,12 @@
{
"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"
}

@ -0,0 +1,8 @@
{
"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"
}

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

@ -0,0 +1,42 @@
{
"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 416 figure is the live `vitest run` count measured 2026-07-19; the suite has The 528 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,9 +275,10 @@ 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 {
@ -516,11 +517,15 @@ 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 */ /* Linked payment/document reference under a done step's date. It WRAPS (up to two short
lines) instead of being ellipsised to one 104px line: the reference exists to show the
payment's amount / the document's number, and a single mono line at this width cut
exactly that off. Wrapping grows the node downwards only, so the track keeps its 112px
columns and its horizontal scroll on narrow screens. */
.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; overflow: hidden; padding: 0; margin-top: 3px; cursor: pointer; max-width: 104px;
text-overflow: ellipsis; white-space: nowrap; white-space: normal; overflow-wrap: anywhere; line-height: 1.3; text-align: center;
} }
.cdr-node .ref:hover { text-decoration: underline; } .cdr-node .ref:hover { text-decoration: underline; }
/* outstanding panel */ /* outstanding panel */

@ -52,6 +52,9 @@ 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])
@ -66,7 +69,9 @@ 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.
const ticketTypes = useData(getTicketTypes, []) // Only needed for that column, so only fetch it once the tickets tab is actually open —
// NewTicketDialog fetches its own copy for the new-ticket type picker, independent of this.
const ticketTypes = useData(() => (tab === 'tickets' ? getTicketTypes() : Promise.resolve(TICKET_TYPE_FALLBACK)), [tab === 'tickets'])
const 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.
@ -99,10 +104,6 @@ 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>
@ -345,6 +346,7 @@ 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 }) }}
@ -1427,7 +1429,10 @@ 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. * bare-toggled one. That reference is resolved against the client's ledger (`payments` /
* `docs`) so it reads as the real date + amount / document number, wraps to a second line
* inside the node rather than being ellipsised, and carries the full detail in its
* tooltip + accessible name.
* "+ 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
@ -1437,7 +1442,10 @@ 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
@ -1547,20 +1555,33 @@ 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. */} step shows only a date, with no evidence the link actually landed. The
{m.done && m.documentId !== null && ( reference wraps inside the node (it used to be ellipsised to one 104px
line, which cut the amount the one thing it exists to show), and the
tooltip/accessible name carries the full detail rather than static text. */}
{m.done && m.documentId !== null && (() => {
const d = props.docs.find((x) => x.id === m.documentId)
const detail = d === undefined
? 'Linked document — open it'
: `${docLabel(d)} ${d.docNo ?? '(draft)'} · ${d.docDate} · ${inr(d.payablePaise)} — open it`
return (
<button <button
type="button" className="ref" title="Open the linked document" type="button" className="ref" title={detail} aria-label={detail}
onClick={() => props.onOpenDocument(m.documentId!)} onClick={() => props.onOpenDocument(m.documentId!)}
> >
view document {d === undefined ? 'view document' : d.docNo ?? `${docLabel(d)} (draft)`}
</button> </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="The linked payment — open Payments & plans" type="button" className="ref" title={detail} aria-label={detail}
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,13 +70,27 @@ export function Dashboard() {
</div> </div>
</section> </section>
{/* SMS low-balance alert (D28) — surfaced here so it's seen without opening the SMS screen. */} {/* SMS low-balance alert (D28) surfaced here so it's seen without opening the SMS
{smsLow.data !== undefined && smsLow.data.lowCount > 0 && ( screen. Three states, never collapsed into one: while the check is in flight a
skeleton stands in; a FAILED check says so with a retry (rendering nothing there
would read as "no client is low" the dangerous direction); and only a loaded,
genuinely-empty result gets the positive line. `unknownCount` is spelled out in
that line because balances the gateway couldn't return aren't "fine" either. */}
{smsLow.error !== undefined ? (
<ErrorState message={`SMS balances could not be checked — ${smsLow.error}`} onRetry={smsLow.reload} />
) : smsLow.data === undefined ? (
<Skeleton rows={1} />
) : smsLow.data.lowCount > 0 ? (
<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). */}
@ -146,11 +160,15 @@ export function Dashboard() {
</Card> </Card>
<Card <Card
title="Onboarding stalled" count={stalled.data?.length ?? 0} title="Onboarding stalled" count={stalled.isLoading ? undefined : (stalled.data?.length ?? 0)}
empty="Nothing stalled." empty="Nothing stalled."
// A fetch failure is not the same as a genuine empty result — show the error // Still fetching (data undefined, no error yet) is not the same as a genuine
// (with retry) instead of quietly reporting "Nothing stalled." on failure. // empty result — show the usual loading placeholder, not "Nothing stalled.",
isEmpty={stalled.error === undefined && (stalled.data ?? []).length === 0} // until the request actually resolves one way or the other.
loading={stalled.isLoading}
// A fetch failure is likewise not the same as empty — show the error (with
// retry) instead of quietly reporting "Nothing stalled." on failure.
isEmpty={!stalled.isLoading && stalled.error === undefined && (stalled.data ?? []).length === 0}
> >
{stalled.error !== undefined {stalled.error !== undefined
? <ErrorState message={stalled.error} onRetry={stalled.reload} /> ? <ErrorState message={stalled.error} onRetry={stalled.reload} />
@ -182,10 +200,12 @@ 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. */ * scrollable list, or an empty-state line when there's nothing to show. Cards fed by
* their own request pass `loading` so a fetch still in flight shows a skeleton instead
* of the empty line "nothing to show" must mean the data loaded and was empty. */
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; children: ReactNode empty: string; isEmpty: boolean; loading?: boolean; children: ReactNode
}) { }) {
return ( return (
<section className="dash-card"> <section className="dash-card">
@ -194,7 +214,9 @@ 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.isEmpty ? <div className="dash-empty">{props.empty}</div> : <div className="dash-list">{props.children}</div>} {props.loading === true
? <div className="dash-list"><Skeleton rows={2} /></div>
: props.isEmpty ? <div className="dash-empty">{props.empty}</div> : <div className="dash-list">{props.children}</div>}
</section> </section>
) )
} }

@ -4,6 +4,8 @@
// 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,6 +1,8 @@
// 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,8 +5,12 @@
// 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: // Run from repo root, naming the database explicitly (this header previously omitted the
// npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts [--force] // DATABASE_URL prefix — followed literally on the production host it resolves to SQLite, not
// the real database, and the script now refuses rather than migrating the wrong DB):
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts [--force]
// To deliberately run against a local SQLite database instead, opt in explicitly:
// HQ_DB_TARGET=sqlite npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts (or --sqlite)
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'

@ -0,0 +1,24 @@
// 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,6 +3,8 @@
// 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,6 +3,8 @@
// 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'

@ -0,0 +1,75 @@
// 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)
})

@ -0,0 +1,76 @@
// 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,7 +815,11 @@ 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)
res.json({ ok: true, projects: await stalledProjects(db, days, today) }) // FIX 4 (no silent caps): unknownAgeExcluded surfaces the count of pending projects the
// query could not prove old (no ticks and no client_module.created_at) instead of
// silently dropping them off the tile with no trace.
const { rows, unknownAgeExcluded } = await stalledProjects(db, days, today)
res.json({ ok: true, projects: rows, unknownAgeExcluded })
} 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) })
} }

@ -0,0 +1,128 @@
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,6 +1,7 @@
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 type { DB } from './db' import { sqliteFilePath, type DB } from './db'
import { PG_MIGRATIONS } from './migrations-pg' import { PG_MIGRATIONS } from './migrations-pg'
/** /**
@ -124,20 +125,61 @@ export function resolveDbUrl(env: NodeJS.ProcessEnv = process.env, hardcoded = H
} }
/** /**
* Same resolution, but hard-fails instead of silently falling back to SQLite when running in * Same resolution, but hard-fails instead of silently falling back to a THROWAWAY SQLite
* production for one-off deploy scripts, where a throwaway SQLite write is never the right * database for one-off deploy/maintenance scripts, where creating a brand-new empty DB is
* outcome (unlike the server, which legitimately runs SQLite in dev). `hardcoded` is * never the right outcome (unlike the server, which legitimately creates one on first boot in
* injectable so tests can simulate the hardcoded URL being accidentally emptied out. * dev this function is never called from server.ts).
*
* The guard is OUTCOME-based, not NODE_ENV-based. The original fix gated the hard-fail on
* `NODE_ENV === 'production'`, but NODE_ENV is exactly what these scripts don't reliably have:
* the server gets it from its service definition, while the scripts are run by hand
* (`npx tsx apps/hq/scripts/…`) from an operator shell with no NODE_ENV and no dotenv. So on
* the real prod box the old guard never fired, resolution returned `''`, and the script
* silently opened/created a brand-new empty SQLite file in `<cwd>/data` "succeeding" against
* a throwaway DB while production was never touched.
*
* Fix: whenever resolution selects SQLite, refuse UNLESS the target file already exists a
* migration/maintenance script must never CREATE a database, regardless of what NODE_ENV is or
* isn't set to. `hardcoded` and `sqliteExists` are both injectable so tests never touch a real
* Postgres URL or the real filesystem.
*
* Second guard (same family of bug, one step earlier): landing on SQLite at all must be a
* DECISION, never a default. An operator who follows a script's documented command with no
* `DATABASE_URL=` in front of it should be told so, not quietly pointed at whatever `./data/hq.db`
* happens to exist under their shell's cwd. So SQLite additionally requires an explicit opt-in
* `HQ_DB_TARGET=sqlite` or a `--sqlite` argv flag (`HQ_DATA_DIR=:memory:` counts too: nobody sets
* that by accident). Without one, the script refuses and the error names both options.
* `argv` is injectable for the same reason as the rest: tests never depend on the real process.
*/ */
export function requireDbUrlForEnv(env: NodeJS.ProcessEnv = process.env, hardcoded = HARDCODED_DATABASE_URL): string { export function requireDbUrlForEnv(
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 === '' && env['NODE_ENV'] === 'production') { if (pgUrl === '') {
const file = sqliteFilePath(env['HQ_DATA_DIR'])
const optedIn =
file === ':memory:' ||
(env['HQ_DB_TARGET'] ?? '').trim().toLowerCase() === 'sqlite' ||
argv.includes('--sqlite')
if (!optedIn) {
throw new Error( throw new Error(
'[db] refusing to run: NODE_ENV=production but no Postgres target resolved ' + '[db] refusing to run: no Postgres target resolved (DATABASE_URL is unset), and SQLite ' +
'(DATABASE_URL is unset and the hardcoded production URL is empty) — this would ' + 'has not been explicitly requested — running against the wrong database is worse than ' +
'otherwise silently create a throwaway SQLite database instead of touching production data.', '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,6 +534,15 @@ 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,12 +1138,15 @@ 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) installed_on, provider, username, password_enc, details, remark, field_values, created_at)
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), JSON.stringify(a.fieldValues), now,
) )
} 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,11 +77,9 @@ 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) {
if (!templateKeys.has(r.key)) alreadyNew = false const isAdHocCustom = !templateKeys.has(r.key) && KEY_MAP[r.key] === undefined && !KNOWN_LEGACY_KEYS.has(r.key)
const isCustom = !templateKeys.has(r.key) && KEY_MAP[r.key] === undefined && !KNOWN_LEGACY_KEYS.has(r.key) if (isAdHocCustom) { customRows.push(r); continue }
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) {
@ -99,10 +97,17 @@ export async function migrateOnboardingTemplates(
dropped++ dropped++
} }
} }
// Idempotency: if the project's keys already ARE exactly the template, skip. // Idempotency: if the project's keys already ARE exactly the template, skip. Computed
const oldKeySet = new Set(oldRows.map((r) => r.key)) // over NON-CUSTOM rows only (regression fix): the old check flagged `alreadyNew = false`
const identical = alreadyNew && oldKeySet.size === templateKeys.size // for ANY row whose key isn't a template key — including deliberately-preserved `custom_*`
&& [...templateKeys].every((k) => oldKeySet.has(k)) // rows (the `addProjectMilestone` "+ Add step" key pattern) — so a project with even one
// ad-hoc step was never "identical" and got DELETE-and-rebuilt on every single run (new
// row ids, a fresh audit row each time), contradicting "safe to re-run / a second run
// does nothing". Using the same discriminator the preservation logic above relies on.
const isCustom = (k: string): boolean => k.startsWith('custom_')
const templateRowKeys = new Set(oldRows.filter((r) => !isCustom(r.key)).map((r) => r.key))
const identical = templateRowKeys.size === templateKeys.size
&& [...templateKeys].every((k) => templateRowKeys.has(k))
if (identical) continue if (identical) continue
projects++ projects++
await db.transaction(async () => { await db.transaction(async () => {

@ -373,4 +373,12 @@ 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,13 +45,25 @@ const STEP_STATUS: Record<string, string> = {
} }
/** /**
* Also replaces the removed UI date-writers (Client Detail redesign dropped `RowDate` / * The two things ticking a milestone does to its parent `client_module`, applied in the
* the summary-table date columns without a replacement): the first time status crosses * caller's transaction and audited together:
* into 'installed' / 'trained', stamp client_module.installed_on / trained_on from the *
* triggering milestone's own done_on never overwriting a date already on file (e.g. one * 1. **Status**, advanced to the step's target never downgraded.
* set by the APEX importer or a direct edit). * 2. **installed_on / trained_on**, stamped from the triggering milestone's own done_on.
* This also replaces the removed UI date-writers (the Client Detail redesign dropped
* `RowDate` / the summary-table date columns without a replacement), so a tick is now the
* ONLY way those columns get filled.
*
* The two are INDEPENDENT (bug fix): the date used to be stamped only as a side effect of a
* status upgrade, so on the ~300 APEX-imported projects already at `live`, the furthest
* status ticking Installation or Training upgraded nothing and therefore recorded nothing,
* leaving the Module Directory's "Installed" column blank forever with no way to fill it.
* Ticking the step now stamps the date whether or not the coarse status moves. Still
* write-once: a date already on file (from the APEX importer or an earlier tick) is never
* overwritten. If neither the status nor a date would change, nothing is written and no audit
* row is added a re-tick stays a no-op.
*/ */
async function advanceStatusForStep( async function applyStepEffects(
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]
@ -59,11 +71,14 @@ async function advanceStatusForStep(
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
if ((STATUS_RANK[cm.status] ?? 0) >= (STATUS_RANK[target] ?? 0)) return // never downgrade const sets: string[] = []
const sets = ['status=?'] const args: unknown[] = []
const args: unknown[] = [target] const before: Record<string, unknown> = {}
const before: Record<string, unknown> = { status: cm.status } const after: Record<string, unknown> = {}
const after: Record<string, unknown> = { status: target } if ((STATUS_RANK[cm.status] ?? 0) < (STATUS_RANK[target] ?? 0)) { // never downgrade
sets.push('status=?'); args.push(target)
before['status'] = cm.status; after['status'] = target
}
if (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
@ -72,6 +87,7 @@ async function advanceStatusForStep(
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)
@ -266,6 +282,13 @@ 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,
@ -282,7 +305,9 @@ 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
let linkedPayment: string | null = null // Nothing supplied for an already-done step means "leave the link alone", not "clear it".
const wasDone = before.done === 1
let linkedPayment: string | null = paymentId === undefined && wasDone ? before.payment_id : null
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=?)`,
@ -292,7 +317,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 = null let linkedDocument: string | null = documentId === undefined && wasDone ? before.document_id : 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=?)`,
@ -301,14 +326,17 @@ 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, done ? linkedPayment : null, done ? linkedDocument : null, clientModuleId, key, done ? 1 : 0, stamp, finalPayment, finalDocument, 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: linkedPayment, documentId: linkedDocument }) { key, done, doneOn: stamp, paymentId: finalPayment, documentId: finalDocument })
if (done) await advanceStatusForStep(db, userId, clientModuleId, key, stamp) if (done) await applyStepEffects(db, userId, clientModuleId, key, stamp)
return listProjectMilestones(db, clientModuleId) return listProjectMilestones(db, clientModuleId)
}) })
} }
@ -339,9 +367,13 @@ 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-clearing logic a single tick uses instead of a bare UPDATE. * payment/document link 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,
@ -414,6 +446,16 @@ 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
@ -423,12 +465,15 @@ export async function projectsPendingMilestone(db: DB, key: string): Promise<Pen
* 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(db: DB, olderThanDays: number, today: string): Promise<PendingProject[]> { export async function stalledProjects(
const rows = await db.all<PendingProject & { last_done: string | null; created_at: string | null }>( db: DB, olderThanDays: number, today: string,
): Promise<StalledProjectsResult> {
const allRows = await db.all<PendingProject & { last_done: string | null; created_at: string | null }>(
`SELECT pm.client_module_id AS "clientModuleId", MIN(cm.client_id) AS "clientId", `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
@ -441,11 +486,19 @@ export async function stalledProjects(db: DB, olderThanDays: number, today: stri
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)
return rows const rows: PendingProject[] = []
.filter((r) => { let unknownAgeExcluded = 0
if (r.last_done !== null) return r.last_done < cutoff for (const r of allRows) {
if (r.created_at !== null) return r.created_at.slice(0, 10) < cutoff const { last_done, created_at, ...rest } = r
return false if (last_done !== null) {
}) if (last_done < cutoff) rows.push(rest)
.map(({ last_done: _last_done, created_at: _created_at, ...rest }) => rest) continue
}
if (created_at !== null) {
if (created_at.slice(0, 10) < cutoff) rows.push(rest)
continue
}
unknownAgeExcluded++
}
return { rows, unknownAgeExcluded }
} }

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

@ -1,6 +1,12 @@
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 () => {
@ -20,4 +26,55 @@ 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 field_values: string; created_at: string | null
}>( }>(
`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.installed_on, cm.field_values, cm.created_at
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,6 +281,9 @@ 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,6 +313,48 @@ 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'

@ -0,0 +1,113 @@
// 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,6 +110,63 @@ 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,9 +34,12 @@ 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[] } const body = await res.json() as { ok: boolean; projects: unknown[]; unknownAgeExcluded: number }
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,10 +27,11 @@ 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.map((p) => p.clientModuleId)).toContain(cm.id) expect(stalled.rows.map((p) => p.clientModuleId)).toContain(cm.id)
const row = stalled.find((p) => p.clientModuleId === cm.id)! const row = stalled.rows.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 () => {
@ -39,7 +40,8 @@ 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).toHaveLength(0) expect(stalled.rows).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 () => {
@ -50,7 +52,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.map((p) => p.clientModuleId)).not.toContain(done.id) expect(stalled.rows.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')
}) })
@ -69,7 +71,8 @@ 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.map((p) => p.clientModuleId)).not.toContain(cm.id) expect(stalled.rows.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 () => {
@ -77,7 +80,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.map((p) => p.clientModuleId)).toContain(cm.id) expect(stalled.rows.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 () => {
@ -87,7 +90,18 @@ 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.map((p) => p.clientModuleId)).not.toContain(cm.id) expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
})
// FIX 4 (no silent caps): the row above used to just vanish with no trace. It must now be
// counted so the UI can surface "N of unknown age" instead of silently under-reporting.
it('a no-tick row with NULL created_at lands in the unknownAgeExcluded count, not just silently dropped', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await db.run(`UPDATE client_module SET created_at=NULL WHERE id=?`, cm.id)
const stalled = await stalledProjects(db, 0, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
expect(stalled.unknownAgeExcluded).toBe(1)
}) })
it('assignModule itself stamps a real (non-null) created_at', async () => { it('assignModule itself stamps a real (non-null) created_at', async () => {

@ -571,13 +571,18 @@ 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)
`scripts/backup.mjs` (`npm run backup`) `pg_dump`s the database to a timestamped, compressed, `apps/hq/scripts/backup.ts``apps/hq/src/backup.ts` (`npm run backup`) `pg_dump`s the
database to a timestamped, compressed,
`pg_restore`-able file under `./backups` (gitignored), keeping the newest `HQ_BACKUP_KEEP` `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": "node scripts/backup.mjs" "backup": "tsx apps/hq/scripts/backup.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.0", "@types/node": "^22.10.0",

@ -194,6 +194,22 @@ 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);

@ -1,78 +0,0 @@
#!/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