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>
main
Thomas Joise 22 hours ago
commit 23b0b64de2

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

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

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

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

@ -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 }
}

@ -142,15 +142,37 @@ export function resolveDbUrl(env: NodeJS.ProcessEnv = process.env, hardcoded = H
* 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,
sqliteExists: (file: string) => boolean = fs.existsSync,
argv: readonly string[] = process.argv,
): string {
const pgUrl = resolveDbUrl(env, hardcoded)
if (pgUrl === '') {
const file = sqliteFilePath(env['HQ_DATA_DIR'])
const optedIn =
file === ':memory:' ||
(env['HQ_DB_TARGET'] ?? '').trim().toLowerCase() === 'sqlite' ||
argv.includes('--sqlite')
if (!optedIn) {
throw new Error(
'[db] refusing to run: no Postgres target resolved (DATABASE_URL is unset), and SQLite ' +
'has not been explicitly requested — running against the wrong database is worse than ' +
'not running at all. Either set DATABASE_URL=postgres://… to target the real database, ' +
`or opt into SQLite explicitly with HQ_DB_TARGET=sqlite (or the --sqlite flag), which ` +
`would use ${file}.`,
)
}
if (file !== ':memory:' && !sqliteExists(file)) {
throw new Error(
`[db] refusing to run: this would CREATE a new SQLite database at ${file}. ` +

@ -45,13 +45,25 @@ const STEP_STATUS: Record<string, string> = {
}
/**
* Also replaces the removed UI date-writers (Client Detail redesign dropped `RowDate` /
* the summary-table date columns without a replacement): the first time status crosses
* into 'installed' / 'trained', stamp client_module.installed_on / trained_on from the
* triggering milestone's own done_on never overwriting a date already on file (e.g. one
* set by the APEX importer or a direct edit).
* The two things ticking a milestone does to its parent `client_module`, applied in the
* caller's transaction and audited together:
*
* 1. **Status**, advanced to the step's target never downgraded.
* 2. **installed_on / trained_on**, stamped from the triggering milestone's own done_on.
* This also replaces the removed UI date-writers (the Client Detail redesign dropped
* `RowDate` / the summary-table date columns without a replacement), so a tick is now the
* ONLY way those columns get filled.
*
* The two are INDEPENDENT (bug fix): the date used to be stamped only as a side effect of a
* status upgrade, so on the ~300 APEX-imported projects already at `live`, the furthest
* status ticking Installation or Training upgraded nothing and therefore recorded nothing,
* leaving the Module Directory's "Installed" column blank forever with no way to fill it.
* Ticking the step now stamps the date whether or not the coarse status moves. Still
* write-once: a date already on file (from the APEX importer or an earlier tick) is never
* overwritten. If neither the status nor a date would change, nothing is written and no audit
* row is added a re-tick stays a no-op.
*/
async function advanceStatusForStep(
async function applyStepEffects(
db: DB, userId: string, clientModuleId: string, key: string, doneOn: string | null,
): Promise<void> {
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 }>(
`SELECT status, installed_on, trained_on FROM client_module WHERE id=?`, clientModuleId)
if (cm === undefined) return
if ((STATUS_RANK[cm.status] ?? 0) >= (STATUS_RANK[target] ?? 0)) return // never downgrade
const sets = ['status=?']
const args: unknown[] = [target]
const before: Record<string, unknown> = { status: cm.status }
const after: Record<string, unknown> = { status: target }
const sets: string[] = []
const args: unknown[] = []
const before: Record<string, unknown> = {}
const after: Record<string, unknown> = {}
if ((STATUS_RANK[cm.status] ?? 0) < (STATUS_RANK[target] ?? 0)) { // never downgrade
sets.push('status=?'); args.push(target)
before['status'] = cm.status; after['status'] = target
}
if (target === 'installed' && cm.installed_on === null && doneOn !== null) {
sets.push('installed_on=?'); args.push(doneOn)
before['installedOn'] = null; after['installedOn'] = doneOn
@ -72,6 +87,7 @@ async function advanceStatusForStep(
sets.push('trained_on=?'); args.push(doneOn)
before['trainedOn'] = null; after['trainedOn'] = doneOn
}
if (sets.length === 0) return
args.push(clientModuleId)
await db.run(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`, ...args)
await writeAudit(db, userId, 'update', 'client_module', clientModuleId, before, after)
@ -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.
* A quotation-step tick (`quotation_sent`) can likewise pass `documentId` to link the
* quotation / proforma that was sent it must belong to the same client too. Audited.
*
* Link lifecycle (bug fix): re-ticking an ALREADY-DONE step without resending its ids keeps
* the links already on file. They used to be written as `done ? linked : null`, so any tick
* that didn't carry the ids wiped them most visibly `bulkSetMilestone`, which never passes
* them, so one bulk re-tick silently cut every project's step loose from its payment /
* document. Passing an explicit empty string still clears a link (that is the "unlink" the
* picker sends), and unticking still clears both, as before.
*/
export async function setMilestone(
db: DB, userId: string, clientModuleId: string, key: string, done: boolean,
@ -282,7 +305,9 @@ export async function setMilestone(
throw new Error('doneOn must be YYYY-MM-DD')
}
let stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null
let linkedPayment: string | null = null
// 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 !== '') {
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=?)`,
@ -292,7 +317,7 @@ export async function setMilestone(
linkedPayment = paymentId
if (doneOn === undefined) stamp = pay.received_on // link date wins unless caller forced one
}
let linkedDocument: string | null = null
let linkedDocument: string | null = documentId === undefined && wasDone ? before.document_id : null
if (done && documentId !== undefined && documentId !== '') {
const doc = await db.get<{ id: string }>(
`SELECT id FROM document WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`,
@ -301,14 +326,17 @@ export async function setMilestone(
if (doc === undefined) throw new Error('Document not found for this client')
linkedDocument = documentId
}
// Unticking always clears both links, whatever was resolved above.
const finalPayment = done ? linkedPayment : null
const finalDocument = done ? linkedDocument : null
await db.run(
`UPDATE project_milestone SET done=?, done_on=?, payment_id=?, document_id=?
WHERE client_module_id=? AND key=?`,
done ? 1 : 0, stamp, 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,
{ key, done, doneOn: stamp, paymentId: linkedPayment, documentId: linkedDocument })
if (done) await advanceStatusForStep(db, userId, clientModuleId, key, stamp)
{ key, done, doneOn: stamp, paymentId: finalPayment, documentId: finalDocument })
if (done) await applyStepEffects(db, userId, clientModuleId, key, stamp)
return listProjectMilestones(db, clientModuleId)
})
}
@ -339,9 +367,13 @@ export async function addProjectMilestone(
* single summary audit row. Only touches projects that actually have the milestone.
*
* FIX F: routes each project through `setMilestone` the SAME status-advance and
* payment/document link-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)
* and a bulk untick left a stale `payment_id`/`document_id` link on the row.
*
* A bulk call never carries per-project ids, so it relies on `setMilestone` PRESERVING the
* links already on an already-done step (see there) otherwise re-ticking a step in bulk
* would wipe every payment/document link that had been set one at a time.
*/
export async function bulkSetMilestone(
db: DB, userId: string, clientModuleIds: string[], key: string, done: boolean, doneOn?: string,

@ -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([])
})
})

@ -35,10 +35,10 @@ describe('requireDbUrlForEnv (deploy-script guard — OUTCOME-based, not NODE_EN
expect(requireDbUrlForEnv({ NODE_ENV: 'production' }, HARDCODED)).toBe(HARDCODED)
})
it('resolves to SQLite without hard-failing when the target file already exists (mocked fs check)', () => {
expect(requireDbUrlForEnv({}, undefined, () => true)).toBe('')
it('resolves to SQLite without hard-failing when it is opted into and the target file exists (mocked fs check)', () => {
expect(requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, () => true)).toBe('')
// Even NODE_ENV=production is fine as long as the SQLite file it would land on already exists.
expect(requireDbUrlForEnv({ NODE_ENV: 'production' }, '', () => true)).toBe('')
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', () => {
@ -52,10 +52,52 @@ describe('requireDbUrlForEnv (deploy-script guard — OUTCOME-based, not NODE_EN
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.
expect(() => requireDbUrlForEnv({}, undefined, () => false)).toThrow(/refusing to run/i)
expect(() => requireDbUrlForEnv({}, undefined, () => false)).toThrow(/CREATE a new SQLite database/)
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' }, '', () => false)).toThrow(/refusing to run/i)
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')
})
})
@ -63,17 +105,20 @@ describe('requireDbUrlForEnv — real filesystem (the actual script entry path,
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 })).toThrow(/refusing to run/i)
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', () => {
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 })).toBe('')
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 })
}

@ -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
// (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

@ -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.)
## 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`
(default 14). It finds `pg_dump` on PATH or in the Windows `C:\Program Files\PostgreSQL\*\bin`
install. Restore: `pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" <dump>`.
Priority rose with D32 — dumps now contain **plaintext credentials**, so the `backups/` dir
must stay access-controlled and off shared drives. Still pending in the ops slice: schedule
it (Task Scheduler / cron), off-box copy, and getting the DB password out of `server.ts`.
It resolves its target through the SAME shared resolver as the server and the maintenance
scripts (`resolveDbUrl`, D19) and logs the resolved host/db before dumping — the original
`scripts/backup.mjs` read `DATABASE_URL` straight off the environment and so could never
reach production, where that variable is not exported.
## D34 — Employee master import + invited/first-login flag (2026-07-19)
Imported the 11-row employee master (`empmaster.xlsx`) into `staff_user` via

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

@ -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