Compare commits
No commits in common. 'main' and 'feat/client-detail-redesign' have entirely different histories.
main
...
feat/clien
@ -1 +0,0 @@
|
||||
sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319
|
||||
@ -1,42 +0,0 @@
|
||||
{
|
||||
"adoptedAt": 1784565886864,
|
||||
"championId": "sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319",
|
||||
"manifest": {
|
||||
"schema": "ruflo.proven-config/v1",
|
||||
"policy": {
|
||||
"ref": "sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319",
|
||||
"value": {
|
||||
"alpha": 0.3,
|
||||
"subjectWeight": 1,
|
||||
"mmrLambda": 0.5,
|
||||
"bodyWeight": 1.5,
|
||||
"typePenaltyFactor": 0.5
|
||||
}
|
||||
},
|
||||
"layer": "framework/node-cli",
|
||||
"compatibility": {
|
||||
"ruflo": ">=3.24.0"
|
||||
},
|
||||
"benchmark": {
|
||||
"corpus": "ADR-081-labelled-v1",
|
||||
"corpusHash": "sha256:2f700b5c363e20a3bd88ce2bc9b87bbbbaa61732c6177894c6ec37890f888982"
|
||||
},
|
||||
"receipt": {
|
||||
"heldOutDelta": 0.07381404928570845,
|
||||
"redblue": "PASS",
|
||||
"drift": 0,
|
||||
"canary": {
|
||||
"rollbackRate": 0,
|
||||
"latencyP95": 244.612458000076,
|
||||
"costPerTask": 0
|
||||
},
|
||||
"receiptCoverage": 1
|
||||
},
|
||||
"platform": [
|
||||
"linux",
|
||||
"macOS",
|
||||
"windows"
|
||||
]
|
||||
},
|
||||
"previous": ""
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
// One-off deploy step for the Client Detail redesign: writes the live onboarding-template
|
||||
// settings with the composed quotation-led pipeline (shared front + per-module tail), then runs
|
||||
// the tick-preserving migration so existing SMS/RTGS/MobileApp projects adopt the new steps.
|
||||
// Idempotent ONLY as long as the templates below haven't been owner-edited since: each setting
|
||||
// is written only if it is still ABSENT, so a plain re-run never clobbers an owner's edit made
|
||||
// in the Modules onboarding-template editor. Pass --force to unconditionally overwrite every
|
||||
// template with the settings below — this DOES clobber owner edits, use with care.
|
||||
// Run from repo root, naming the database explicitly (this header previously omitted the
|
||||
// DATABASE_URL prefix — followed literally on the production host it resolves to SQLite, not
|
||||
// the real database, and the script now refuses rather than migrating the wrong DB):
|
||||
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts [--force]
|
||||
// To deliberately run against a local SQLite database instead, opt in explicitly:
|
||||
// HQ_DB_TARGET=sqlite npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts (or --sqlite)
|
||||
import { openDb, sqliteFilePath } from '../src/db'
|
||||
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
|
||||
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
|
||||
import { getSetting, setSetting } from '../src/repos-reminders'
|
||||
import type { DB } from '../src/db'
|
||||
|
||||
const FRONT = [
|
||||
{ key: 'enquiry', label: 'Enquiry received' },
|
||||
{ key: 'visit_meeting', label: 'Visit / meeting scheduled' },
|
||||
{ key: 'quotation_sent', label: 'Quotation sent' },
|
||||
{ key: 'quotation_approved', label: 'Quotation approved' },
|
||||
]
|
||||
const TAILS: Record<string, { key: string; label: string }[]> = {
|
||||
SMS: [
|
||||
{ key: 'document_collection', label: 'Document collection' },
|
||||
{ key: 'dlt_registration', label: 'DLT registration' },
|
||||
{ key: 'account_creation', label: 'Account creation & registration' },
|
||||
{ key: 'installation', label: 'Installation' },
|
||||
{ key: 'testing', label: 'Testing' },
|
||||
{ key: 'training', label: 'Training' },
|
||||
{ key: 'go_live', label: 'Go-live' },
|
||||
{ key: 'payment_received', label: 'Payment received' },
|
||||
],
|
||||
RTGS: [
|
||||
{ key: 'document_collection', label: 'Document collection' },
|
||||
{ key: 'server_checkup', label: 'Server checkup' },
|
||||
{ key: 'setup_configuration', label: 'Setup & configuration' },
|
||||
{ key: 'installation', label: 'Installation' },
|
||||
{ key: 'testing', label: 'Testing' },
|
||||
{ key: 'training', label: 'Training' },
|
||||
{ key: 'go_live', label: 'Go-live' },
|
||||
{ key: 'payment_received', label: 'Payment received' },
|
||||
],
|
||||
MOBILEAPP: [
|
||||
{ key: 'requirement_setup', label: 'Requirement & setup' },
|
||||
{ key: 'configuration', label: 'Configuration' },
|
||||
{ key: 'data_import', label: 'Data import' },
|
||||
{ key: 'installation', label: 'Installation' },
|
||||
{ key: 'testing', label: 'Testing' },
|
||||
{ key: 'training', label: 'Training' },
|
||||
{ key: 'go_live', label: 'Go-live' },
|
||||
{ key: 'payment_received', label: 'Payment received' },
|
||||
],
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Pre-ship audit FIX A: the SAME resolver server.ts uses (never re-derived locally) — and,
|
||||
// unlike the server, this one-off script hard-fails rather than silently falling back to a
|
||||
// throwaway SQLite file when NODE_ENV=production resolves no Postgres target.
|
||||
const pgUrl = requireDbUrlForEnv()
|
||||
if (pgUrl !== '') {
|
||||
console.log(`[db] engine: postgres ${pgTargetDescription(pgUrl)}`)
|
||||
} else {
|
||||
console.log(`[db] engine: sqlite at ${sqliteFilePath(process.env['HQ_DATA_DIR'])}`)
|
||||
}
|
||||
const db: DB = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
|
||||
try {
|
||||
const force = process.argv.includes('--force')
|
||||
|
||||
// Audited via setSetting (append-only-audit rule) and, unless --force, skips any key
|
||||
// that's already set — so an owner's edit in the Modules onboarding-template editor is
|
||||
// left alone on a re-run instead of being silently clobbered.
|
||||
const upsert = async (key: string, list: unknown): Promise<void> => {
|
||||
if (!force && (await getSetting(db, key)) !== null) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Skipped ${key} — already set (pass --force to overwrite an owner edit).`)
|
||||
return
|
||||
}
|
||||
await setSetting(db, 'system', key, JSON.stringify(list))
|
||||
}
|
||||
await upsert('project.milestone_template.front', FRONT)
|
||||
for (const [code, list] of Object.entries(TAILS)) await upsert(`project.milestone_template:${code}`, list)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Template settings step done: front + SMS/RTGS/MOBILEAPP tails.')
|
||||
|
||||
const res = await migrateOnboardingTemplates(db)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Onboarding migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ` +
|
||||
`${res.dropped} dropped, ${res.collapsed} collapsed (two old ticks -> one key), ` +
|
||||
`${res.preserved} custom step(s) preserved.`,
|
||||
)
|
||||
} finally {
|
||||
await db.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
@ -1,24 +0,0 @@
|
||||
// apps/hq/scripts/backup.ts — `npm run backup` (D33). Dumps the production Postgres to a
|
||||
// timestamped, compressed, restorable file under ./backups (or $HQ_BACKUP_DIR) and rotates to
|
||||
// the newest $HQ_BACKUP_KEEP (default 14). Run from the repo root:
|
||||
// npm run backup
|
||||
// DATABASE_URL=postgres://… npm run backup (explicit target, overrides resolution)
|
||||
// The target is resolved through the SAME shared resolver the server and every other
|
||||
// maintenance script uses (`requireDbUrlForEnv`, D19) — the old scripts/backup.mjs read
|
||||
// DATABASE_URL directly and so could never find the production database, which is resolved
|
||||
// rather than exported on that box. The resolved host/db is logged (never the password)
|
||||
// before pg_dump runs.
|
||||
//
|
||||
// Restore a dump with:
|
||||
// pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" backups/hq-<ts>.dump
|
||||
//
|
||||
// SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext
|
||||
// logins. Keep backups/ access-controlled and off any shared drive (it is gitignored).
|
||||
import { runBackup } from '../src/backup'
|
||||
|
||||
try {
|
||||
runBackup()
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
process.exit(1)
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
// Go-live data correction: the earlier migrate-sqlite-to-pg.ts run pulled from local SQLite,
|
||||
// which turned out to be stale/near-empty — the real working data was in a local Postgres
|
||||
// instance instead. This wipes the TARGET (cloud) hq database's public schema and reloads it
|
||||
// from the real SOURCE (local Postgres). Both engines are Postgres here, so every column type
|
||||
// matches exactly — no conversion needed.
|
||||
// SOURCE_DATABASE_URL and TARGET_DATABASE_URL must both be set explicitly, no defaults, so
|
||||
// this can never accidentally point at (or wipe) the wrong database.
|
||||
//
|
||||
// Run from repo root:
|
||||
// SOURCE_DATABASE_URL=postgres://hq:<local-pw>@127.0.0.1:5432/hq ^
|
||||
// TARGET_DATABASE_URL=postgres://postgres:<cloud-pw>@13.232.53.115:5432/hq ^
|
||||
// npx tsx apps/hq/scripts/migrate-pg-to-pg.ts
|
||||
import { Pool } from 'pg'
|
||||
import { openPgDb, type PgDb } from '../src/db-pg'
|
||||
|
||||
const SKIP_TABLES = new Set(['stg_client', 'stg_invoice', 'schema_migrations'])
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const sourceUrl = process.env['SOURCE_DATABASE_URL']
|
||||
const targetUrl = process.env['TARGET_DATABASE_URL']
|
||||
if (sourceUrl === undefined || sourceUrl === '') throw new Error('Set SOURCE_DATABASE_URL (local Postgres).')
|
||||
if (targetUrl === undefined || targetUrl === '') throw new Error('Set TARGET_DATABASE_URL (cloud Postgres).')
|
||||
if (sourceUrl === targetUrl) throw new Error('SOURCE and TARGET are identical — refusing.')
|
||||
|
||||
// Wipe the target's public schema completely, then let the app's own migrations rebuild
|
||||
// it clean — guarantees the target schema matches this codebase exactly, not a stale copy.
|
||||
const wipePool = new Pool({ connectionString: targetUrl, max: 1 })
|
||||
await wipePool.query('DROP SCHEMA public CASCADE')
|
||||
await wipePool.query('CREATE SCHEMA public')
|
||||
await wipePool.end()
|
||||
console.log('Target schema wiped.')
|
||||
|
||||
const target = (await openPgDb(targetUrl)) as PgDb // recreates schema via migrations, empty
|
||||
const source = (await openPgDb(sourceUrl)) as PgDb // idempotent no-op if already migrated
|
||||
|
||||
const tables = await source.all<{ tablename: string }>(`SELECT tablename FROM pg_tables WHERE schemaname='public'`)
|
||||
|
||||
const client = await target.pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
// Load in discovery order and ignore FK/trigger checks — safe: one transaction into a
|
||||
// target we just wiped and confirmed empty.
|
||||
await client.query('SET LOCAL session_replication_role = replica')
|
||||
for (const { tablename: name } of tables) {
|
||||
if (SKIP_TABLES.has(name)) {
|
||||
console.log(`skip ${name}`)
|
||||
continue
|
||||
}
|
||||
const rows = await source.all<Record<string, unknown>>(`SELECT * FROM ${name}`)
|
||||
if (rows.length === 0) {
|
||||
console.log(`${name}: 0 rows`)
|
||||
continue
|
||||
}
|
||||
const cols = Object.keys(rows[0]!)
|
||||
const placeholders = cols.map((_, i) => `$${i + 1}`).join(',')
|
||||
const insertSql = `INSERT INTO ${name} (${cols.join(',')}) VALUES (${placeholders})`
|
||||
for (const row of rows) {
|
||||
await client.query(insertSql, cols.map((c) => row[c]))
|
||||
}
|
||||
console.log(`${name}: ${rows.length} row(s) migrated`)
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
console.log('Migration complete.')
|
||||
}
|
||||
|
||||
main().catch((e: unknown) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
@ -1,76 +0,0 @@
|
||||
// Go-live data carry-over: copies every row from the local SQLite dev database into the
|
||||
// target (cloud) Postgres database, preserving values and ids as-is — this is a one-time
|
||||
// full copy, not the "born in Postgres" empty-start path in docs/DEPLOY-HQ.md.
|
||||
// Column types are a straight 1:1 map on this schema (money is bigint paise, flags stay
|
||||
// integer, JSON payloads stay text — see migrations-pg.ts header), so no value conversion
|
||||
// is needed; every column is copied through unchanged.
|
||||
// Refuses to run if the target already has staff_user rows, so a re-run (or pointing at
|
||||
// the wrong DATABASE_URL) can't silently duplicate or clobber data.
|
||||
// stg_client/stg_invoice are APEX-import scratch tables, not real data — skipped.
|
||||
//
|
||||
// Run from repo root, source = local ./data/hq.db (or HQ_DATA_DIR), target = DATABASE_URL:
|
||||
// DATABASE_URL=postgres://postgres:<pw>@<host>:5432/hq npx tsx apps/hq/scripts/migrate-sqlite-to-pg.ts
|
||||
import { openDb, type SqliteDb } from '../src/db'
|
||||
import { openPgDb, type PgDb } from '../src/db-pg'
|
||||
|
||||
const SKIP_TABLES = new Set(['stg_client', 'stg_invoice', 'schema_migrations'])
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const pgUrl = process.env['DATABASE_URL']
|
||||
if (pgUrl === undefined || pgUrl === '') {
|
||||
throw new Error('Set DATABASE_URL to the target (cloud) Postgres connection string.')
|
||||
}
|
||||
|
||||
const sqlite = openDb(process.env['HQ_DATA_DIR']) as SqliteDb
|
||||
const pg = (await openPgDb(pgUrl)) as PgDb // schema is created by migrations here; seedIfEmpty is NOT called
|
||||
|
||||
const existingStaff = (await pg.get<{ n: number }>(`SELECT COUNT(*) AS n FROM staff_user`))!.n
|
||||
if (existingStaff > 0) {
|
||||
throw new Error(
|
||||
`Target already has ${existingStaff} staff_user row(s) — refusing to import over existing data. ` +
|
||||
`Wrong DATABASE_URL, or this has already been migrated.`,
|
||||
)
|
||||
}
|
||||
|
||||
const tables = sqlite.raw
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
|
||||
.all() as { name: string }[]
|
||||
|
||||
const client = await pg.pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
// Load in table-discovery order and ignore FK/trigger checks — safe here because the
|
||||
// whole load is one transaction into a database we just confirmed is empty.
|
||||
await client.query('SET LOCAL session_replication_role = replica')
|
||||
for (const { name } of tables) {
|
||||
if (SKIP_TABLES.has(name)) {
|
||||
console.log(`skip ${name}`)
|
||||
continue
|
||||
}
|
||||
const rows = sqlite.raw.prepare(`SELECT * FROM ${name}`).all() as Record<string, unknown>[]
|
||||
if (rows.length === 0) {
|
||||
console.log(`${name}: 0 rows`)
|
||||
continue
|
||||
}
|
||||
const cols = Object.keys(rows[0]!)
|
||||
const placeholders = cols.map((_, i) => `$${i + 1}`).join(',')
|
||||
const insertSql = `INSERT INTO ${name} (${cols.join(',')}) VALUES (${placeholders})`
|
||||
for (const row of rows) {
|
||||
await client.query(insertSql, cols.map((c) => row[c]))
|
||||
}
|
||||
console.log(`${name}: ${rows.length} row(s) migrated`)
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
console.log('Migration complete.')
|
||||
}
|
||||
|
||||
main().catch((e: unknown) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
@ -1,128 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { sqliteFilePath } from './db'
|
||||
import { pgTargetDescription, resolveDbUrl } from './db-pg'
|
||||
|
||||
/**
|
||||
* Database backup (D33): `pg_dump` of the production Postgres to a timestamped, compressed,
|
||||
* restorable file under ./backups (or $HQ_BACKUP_DIR), rotated to the newest N.
|
||||
*
|
||||
* Lives in src/ (not scripts/) so it is typechecked and unit-testable; `scripts/backup.ts` is
|
||||
* the thin CLI wrapper `npm run backup` invokes.
|
||||
*
|
||||
* The fix this file exists for: the previous `scripts/backup.mjs` read `process.env.DATABASE_URL`
|
||||
* directly and bailed out when it was unset. On the production box DATABASE_URL is NOT set — the
|
||||
* connection is resolved by `resolveDbUrl` (D19) — so the backup command could never reach the
|
||||
* real database. It now resolves its target through the SAME shared resolver every other
|
||||
* maintenance script uses, and logs the resolved target (never the password) before it runs.
|
||||
*
|
||||
* SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext logins.
|
||||
* Keep the backups/ directory access-controlled and off any shared drive; it is gitignored so it
|
||||
* can never be committed.
|
||||
*/
|
||||
|
||||
/** Env knobs, defaulted here so the CLI wrapper stays a one-liner. */
|
||||
export const DEFAULT_KEEP = 14
|
||||
|
||||
/**
|
||||
* The Postgres URL this backup run must dump — via `resolveDbUrl`, the one shared resolver
|
||||
* (D19: DATABASE_URL, else the production connection). This is the fix: the target is
|
||||
* RESOLVED exactly as the server resolves it, instead of being read straight off
|
||||
* `process.env.DATABASE_URL`, which is not set on the production box.
|
||||
*
|
||||
* A SQLite outcome is fatal here rather than an opt-in (unlike the migration scripts, which
|
||||
* can legitimately run against SQLite): `pg_dump` cannot read a SQLite file at all, so the
|
||||
* only useful answer is a loud error naming the SQLite snapshot script instead.
|
||||
*/
|
||||
export function resolveBackupTarget(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const url = resolveDbUrl(env)
|
||||
if (url === '') {
|
||||
throw new Error(
|
||||
'[backup] refusing to run: no Postgres target resolved, so this would back up nothing. ' +
|
||||
`The database resolved to SQLite at ${sqliteFilePath(env['HQ_DATA_DIR'])}, and this ` +
|
||||
'backup dumps Postgres (pg_dump). Set DATABASE_URL=postgres://… to back up the real ' +
|
||||
'database, or use apps/hq/scripts/backup-hq.sh for a SQLite snapshot.',
|
||||
)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/** Locate pg_dump: PATH first, then a standard Windows PostgreSQL install. */
|
||||
export function findPgDump(): string | null {
|
||||
try {
|
||||
execFileSync(process.platform === 'win32' ? 'where' : 'which', ['pg_dump'], { stdio: 'ignore' })
|
||||
return 'pg_dump'
|
||||
} catch { /* not on PATH */ }
|
||||
if (process.platform === 'win32') {
|
||||
const base = 'C:/Program Files/PostgreSQL'
|
||||
try {
|
||||
for (const v of readdirSync(base).sort().reverse()) {
|
||||
const cand = join(base, v, 'bin', 'pg_dump.exe')
|
||||
if (existsSync(cand)) return cand
|
||||
}
|
||||
} catch { /* no install dir */ }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** `hq-YYYYMMDD-HHMMSS.dump` for the given local time. Pure — the rotation regex must match it. */
|
||||
export function backupFileName(now: Date): string {
|
||||
const p2 = (n: number): string => String(n).padStart(2, '0')
|
||||
const ts = `${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}-` +
|
||||
`${p2(now.getHours())}${p2(now.getMinutes())}${p2(now.getSeconds())}`
|
||||
return `hq-${ts}.dump`
|
||||
}
|
||||
|
||||
const DUMP_RE = /^hq-\d{8}-\d{6}\.dump$/
|
||||
|
||||
/** Which of `files` fall outside the newest `keep` dumps. Pure, so rotation is testable. */
|
||||
export function dumpsToRotate(files: readonly string[], keep: number): string[] {
|
||||
const dumps = files.filter((f) => DUMP_RE.test(f)).sort() // name sorts chronologically
|
||||
return dumps.slice(0, Math.max(0, dumps.length - Math.max(1, keep)))
|
||||
}
|
||||
|
||||
export interface BackupResult { file: string; bytes: number; rotated: string[]; retained: number }
|
||||
|
||||
/**
|
||||
* Run the backup end to end. `log` is injected so the CLI prints and tests stay quiet.
|
||||
* Env: HQ_BACKUP_DIR (default ./backups), HQ_BACKUP_KEEP (default 14).
|
||||
*/
|
||||
export function runBackup(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
log: (msg: string) => void = console.log,
|
||||
): BackupResult {
|
||||
const url = resolveBackupTarget(env)
|
||||
// Log the resolved target BEFORE doing anything: a backup that quietly dumped the wrong
|
||||
// database is indistinguishable from a correct one until the day you need the restore.
|
||||
log(`[db] engine: postgres ${pgTargetDescription(url)}`)
|
||||
|
||||
const pgDump = findPgDump()
|
||||
if (pgDump === null) {
|
||||
throw new Error('[backup] pg_dump not found. Install the PostgreSQL client tools, or add pg_dump to PATH.')
|
||||
}
|
||||
|
||||
const out = env['HQ_BACKUP_DIR'] || join(process.cwd(), 'backups')
|
||||
const keep = Math.max(1, Number(env['HQ_BACKUP_KEEP'] || DEFAULT_KEEP))
|
||||
mkdirSync(out, { recursive: true })
|
||||
const file = join(out, backupFileName(new Date()))
|
||||
|
||||
// -Fc = custom, compressed, restorable with pg_restore. --no-owner keeps it portable.
|
||||
try {
|
||||
execFileSync(pgDump, ['-Fc', '--no-owner', '-f', file, url], { stdio: ['ignore', 'inherit', 'inherit'] })
|
||||
} catch (e) {
|
||||
throw new Error(`[backup] pg_dump failed: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
|
||||
const bytes = statSync(file).size
|
||||
log(`✓ backup written: ${file} (${(bytes / 1024).toFixed(1)} KB)`)
|
||||
|
||||
const rotated = dumpsToRotate(readdirSync(out), keep)
|
||||
for (const f of rotated) {
|
||||
unlinkSync(join(out, f))
|
||||
log(` rotated out: ${f}`)
|
||||
}
|
||||
const retained = readdirSync(out).filter((f) => DUMP_RE.test(f)).length
|
||||
log(`retained ${retained} backup(s) in ${out} (HQ_BACKUP_KEEP=${keep})`)
|
||||
return { file, bytes, rotated, retained }
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
// apps/hq/test/backup.test.ts — `npm run backup` (D33) must find the PRODUCTION database.
|
||||
// It used to read process.env.DATABASE_URL directly and exit when it was unset — but on the
|
||||
// prod box DATABASE_URL is not exported; the connection is RESOLVED (D19). So the backup
|
||||
// command could never reach the real data. It now goes through the one shared resolver, and
|
||||
// a SQLite outcome is a loud error rather than a pg_dump against a file it cannot read.
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { backupFileName, dumpsToRotate, resolveBackupTarget } from '../src/backup'
|
||||
|
||||
describe('resolveBackupTarget (same resolver as the server + every maintenance script)', () => {
|
||||
it('uses DATABASE_URL when it is set', () => {
|
||||
expect(resolveBackupTarget({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
|
||||
})
|
||||
|
||||
it('resolves the production target with no DATABASE_URL exported — the whole point of the fix', () => {
|
||||
// NODE_ENV=production with no DATABASE_URL used to be an instant "DATABASE_URL is not set"
|
||||
// exit; it must now resolve to the same Postgres the server picks.
|
||||
const url = resolveBackupTarget({ NODE_ENV: 'production' })
|
||||
expect(url).toMatch(/^postgres:\/\//)
|
||||
})
|
||||
|
||||
it('refuses (never silently no-ops) when nothing resolves a target', () => {
|
||||
expect(() => resolveBackupTarget({})).toThrow(/refusing to run/i)
|
||||
})
|
||||
|
||||
it('says WHY, naming pg_dump, the SQLite path it landed on, and the SQLite snapshot script', () => {
|
||||
const err = (() => { try { resolveBackupTarget({ HQ_DATA_DIR: 'D:/hq-data' }); return '' } catch (e) { return String(e) } })()
|
||||
expect(err).toMatch(/pg_dump/)
|
||||
expect(err).toMatch(/backup-hq\.sh/)
|
||||
expect(err).toMatch(/hq\.db/)
|
||||
expect(err).toMatch(/DATABASE_URL=postgres/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('backup file naming + rotation', () => {
|
||||
it('names dumps hq-YYYYMMDD-HHMMSS.dump', () => {
|
||||
expect(backupFileName(new Date(2026, 6, 19, 2, 5, 9))).toBe('hq-20260719-020509.dump')
|
||||
})
|
||||
|
||||
it('rotates out everything past the newest KEEP, ignoring unrelated files', () => {
|
||||
const files = [
|
||||
'hq-20260101-000000.dump', 'hq-20260102-000000.dump', 'hq-20260103-000000.dump',
|
||||
'notes.txt', 'hq-old.dump',
|
||||
]
|
||||
expect(dumpsToRotate(files, 2)).toEqual(['hq-20260101-000000.dump'])
|
||||
expect(dumpsToRotate(files, 14)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps at least one dump however low KEEP is set', () => {
|
||||
expect(dumpsToRotate(['hq-20260101-000000.dump'], 0)).toEqual([])
|
||||
})
|
||||
})
|
||||
@ -1,149 +0,0 @@
|
||||
// apps/hq/test/db-resolve.test.ts — pre-ship audit FIX A: server.ts and the deploy scripts
|
||||
// (apps/hq/scripts/apply-onboarding-pipeline.ts, apps/hq/scripts/migrate-onboarding-templates.ts)
|
||||
// must all resolve the DB engine + target through the ONE shared function. Before this fix
|
||||
// the scripts computed it as `DATABASE_URL ?? ''` (no production fallback), so on the
|
||||
// production box — where DATABASE_URL is unset — each script silently opened a brand-new
|
||||
// empty SQLite file instead of the real Postgres.
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { pgTargetDescription, requireDbUrlForEnv, resolveDbUrl } from '../src/db-pg'
|
||||
|
||||
const HARDCODED = 'postgres://u:p@h:5432/db'
|
||||
|
||||
describe('resolveDbUrl (D19 engine selection, the one shared resolver)', () => {
|
||||
it('DATABASE_URL wins regardless of NODE_ENV', () => {
|
||||
expect(resolveDbUrl({ DATABASE_URL: 'postgres://x/y', NODE_ENV: 'production' })).toBe('postgres://x/y')
|
||||
expect(resolveDbUrl({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
|
||||
})
|
||||
|
||||
it('falls back to the hardcoded production URL only when NODE_ENV=production and DATABASE_URL is unset', () => {
|
||||
expect(resolveDbUrl({ NODE_ENV: 'production' }, HARDCODED)).toBe(HARDCODED)
|
||||
})
|
||||
|
||||
it('resolves to SQLite (empty string) outside production with no DATABASE_URL — dev/tests stay green', () => {
|
||||
expect(resolveDbUrl({})).toBe('')
|
||||
expect(resolveDbUrl({ NODE_ENV: 'development' })).toBe('')
|
||||
expect(resolveDbUrl({ NODE_ENV: 'test' })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireDbUrlForEnv (deploy-script guard — OUTCOME-based, not NODE_ENV-based)', () => {
|
||||
it('behaves exactly like resolveDbUrl when a Postgres target is available (no SQLite file check runs at all)', () => {
|
||||
expect(requireDbUrlForEnv({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
|
||||
expect(requireDbUrlForEnv({ NODE_ENV: 'production' }, HARDCODED)).toBe(HARDCODED)
|
||||
})
|
||||
|
||||
it('resolves to SQLite without hard-failing when it is opted into and the target file exists (mocked fs check)', () => {
|
||||
expect(requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, () => true)).toBe('')
|
||||
// Even NODE_ENV=production is fine as long as the SQLite file it would land on already exists.
|
||||
expect(requireDbUrlForEnv({ NODE_ENV: 'production', HQ_DB_TARGET: 'sqlite' }, '', () => true)).toBe('')
|
||||
})
|
||||
|
||||
it(':memory: never hard-fails — the guard never even calls the file-existence check', () => {
|
||||
expect(
|
||||
requireDbUrlForEnv({ HQ_DATA_DIR: ':memory:' }, undefined, () => {
|
||||
throw new Error('must not be called for :memory:')
|
||||
}),
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('refuses when resolution selects SQLite and the target does not exist — regardless of NODE_ENV', () => {
|
||||
// This is the original prod bug: an operator shell has no NODE_ENV set at all, so the old
|
||||
// NODE_ENV==='production' gate never fired. The guard must fire on the OUTCOME instead.
|
||||
const optIn = { HQ_DB_TARGET: 'sqlite' }
|
||||
expect(() => requireDbUrlForEnv(optIn, undefined, () => false)).toThrow(/refusing to run/i)
|
||||
expect(() => requireDbUrlForEnv(optIn, undefined, () => false)).toThrow(/CREATE a new SQLite database/)
|
||||
// Also still refuses in the NODE_ENV=production case (hardcoded emptied out + no existing file).
|
||||
expect(() => requireDbUrlForEnv({ NODE_ENV: 'production', HQ_DB_TARGET: 'sqlite' }, '', () => false))
|
||||
.toThrow(/refusing to run/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireDbUrlForEnv — SQLite must be an explicit opt-in, never an accident', () => {
|
||||
// The bug this closes: apply-onboarding-pipeline.ts documented its own invocation WITHOUT a
|
||||
// `DATABASE_URL=` prefix. An operator who copy-pastes that on the prod box resolves to SQLite;
|
||||
// if a stale ./data/hq.db happens to exist under their cwd, the file-existence guard passes and
|
||||
// the migration "succeeds" against the wrong database. Landing on SQLite must be a decision.
|
||||
const existingFile = (): boolean => true
|
||||
|
||||
it('refuses when nothing selects a target, even though the SQLite file exists', () => {
|
||||
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/refusing to run/i)
|
||||
})
|
||||
|
||||
it('names BOTH escape hatches in the error — DATABASE_URL and the SQLite opt-in', () => {
|
||||
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/DATABASE_URL=postgres/)
|
||||
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/HQ_DB_TARGET=sqlite/)
|
||||
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/--sqlite/)
|
||||
})
|
||||
|
||||
it('accepts HQ_DB_TARGET=sqlite (case/space tolerant)', () => {
|
||||
expect(requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, existingFile)).toBe('')
|
||||
expect(requireDbUrlForEnv({ HQ_DB_TARGET: ' SQLite ' }, undefined, existingFile)).toBe('')
|
||||
})
|
||||
|
||||
it('accepts a --sqlite argv flag (alongside the script\'s own flags)', () => {
|
||||
expect(requireDbUrlForEnv({}, undefined, existingFile, ['node', 'script.ts', '--force', '--sqlite'])).toBe('')
|
||||
})
|
||||
|
||||
it('rejects an unrelated HQ_DB_TARGET value rather than guessing', () => {
|
||||
expect(() => requireDbUrlForEnv({ HQ_DB_TARGET: 'postgres' }, undefined, existingFile)).toThrow(/refusing to run/i)
|
||||
})
|
||||
|
||||
it('the opt-in does NOT weaken the "never CREATE a database" guard', () => {
|
||||
expect(() => requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, () => false))
|
||||
.toThrow(/CREATE a new SQLite database/)
|
||||
})
|
||||
|
||||
it('DATABASE_URL needs no opt-in — Postgres is the intended target and never touches the flag', () => {
|
||||
expect(requireDbUrlForEnv({ DATABASE_URL: 'postgres://x/y' }, undefined, () => false, [])).toBe('postgres://x/y')
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireDbUrlForEnv — real filesystem (the actual script entry path, no mocks)', () => {
|
||||
it('refuses a non-existent SQLite path', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-db-resolve-'))
|
||||
try {
|
||||
expect(() => requireDbUrlForEnv({ HQ_DATA_DIR: dir, HQ_DB_TARGET: 'sqlite' }))
|
||||
.toThrow(/CREATE a new SQLite database/)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts an existing SQLite path once SQLite is opted into', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-db-resolve-'))
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'hq.db'), '')
|
||||
expect(requireDbUrlForEnv({ HQ_DATA_DIR: dir, HQ_DB_TARGET: 'sqlite' })).toBe('')
|
||||
// …and still refuses the same existing path with no opt-in at all.
|
||||
expect(() => requireDbUrlForEnv({ HQ_DATA_DIR: dir })).toThrow(/HQ_DB_TARGET=sqlite/)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it(':memory: still works with no filesystem check', () => {
|
||||
expect(requireDbUrlForEnv({ HQ_DATA_DIR: ':memory:' })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pgTargetDescription (never leaks the password into logs)', () => {
|
||||
it('extracts host:port/dbname', () => {
|
||||
expect(pgTargetDescription('postgres://user:secret@db.example.com:5432/hq'))
|
||||
.toBe('db.example.com:5432/hq')
|
||||
})
|
||||
|
||||
it('omits the port when absent', () => {
|
||||
expect(pgTargetDescription('postgres://user:secret@db.example.com/hq')).toBe('db.example.com/hq')
|
||||
})
|
||||
|
||||
it('never includes the credentials', () => {
|
||||
expect(pgTargetDescription('postgres://user:hunter2@host/db')).not.toContain('hunter2')
|
||||
})
|
||||
|
||||
it('degrades gracefully on an unparsable string', () => {
|
||||
expect(pgTargetDescription('not-a-url')).toMatch(/unparsable/i)
|
||||
})
|
||||
})
|
||||
@ -1,122 +0,0 @@
|
||||
// apps/hq/test/milestone-document.test.ts — Client Detail redesign task 7: the quotation
|
||||
// step (`quotation_sent`) can link the quotation / proforma document that was actually sent.
|
||||
import express from 'express'
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { openDb } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { apiRouter } from '../src/api'
|
||||
import { createStaff } from '../src/auth'
|
||||
import { createClient } from '../src/repos-clients'
|
||||
import { createModule, setPrice, assignModule } from '../src/repos-modules'
|
||||
import { createDraft } from '../src/repos-documents'
|
||||
import { setMilestone, listProjectMilestones } from '../src/repos-milestones'
|
||||
|
||||
const KEY = '11'.repeat(32)
|
||||
|
||||
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 quote = async (clientId: string) => createDraft(db, 'u1', {
|
||||
docType: 'QUOTATION', clientId, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
|
||||
})
|
||||
return { db, clientId: c.id, cmId: cm.id, quote }
|
||||
}
|
||||
|
||||
const step = async (db: Awaited<ReturnType<typeof world>>['db'], cmId: string) =>
|
||||
(await listProjectMilestones(db, cmId)).find((s) => s.key === 'quotation_sent')!
|
||||
|
||||
describe('milestone document_id link (quotation step)', () => {
|
||||
it('linking a quotation stores document_id on the ticked step', async () => {
|
||||
const { db, clientId, cmId, quote } = await world()
|
||||
const doc = await quote(clientId)
|
||||
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, '2026-05-01', undefined, doc.id)
|
||||
const s = await step(db, cmId)
|
||||
expect(s.done).toBe(true)
|
||||
expect(s.doneOn).toBe('2026-05-01')
|
||||
expect(s.documentId).toBe(doc.id)
|
||||
})
|
||||
|
||||
it('rejects a document that belongs to a different client', async () => {
|
||||
const { db, cmId, quote } = await world()
|
||||
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
|
||||
const foreign = await quote(other.id)
|
||||
await expect(
|
||||
setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, foreign.id),
|
||||
).rejects.toThrow(/document/i)
|
||||
// …and nothing was written: the step is still pending.
|
||||
const s = await step(db, cmId)
|
||||
expect(s.done).toBe(false)
|
||||
expect(s.documentId).toBeNull()
|
||||
})
|
||||
|
||||
it('unticking clears document_id along with done_on', async () => {
|
||||
const { db, clientId, cmId, quote } = await world()
|
||||
const doc = await quote(clientId)
|
||||
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, doc.id)
|
||||
expect((await step(db, cmId)).documentId).toBe(doc.id)
|
||||
await setMilestone(db, 'u1', cmId, 'quotation_sent', false)
|
||||
const s = await step(db, cmId)
|
||||
expect(s.done).toBe(false)
|
||||
expect(s.doneOn).toBeNull()
|
||||
expect(s.documentId).toBeNull()
|
||||
})
|
||||
|
||||
it('ticking without a documentId leaves the link null (opt-in)', async () => {
|
||||
const { db, cmId } = await world()
|
||||
await setMilestone(db, 'u1', cmId, 'quotation_sent', true)
|
||||
const s = await step(db, cmId)
|
||||
expect(s.done).toBe(true)
|
||||
expect(s.documentId).toBeNull()
|
||||
})
|
||||
|
||||
it('audits the link in the same transaction as the tick', async () => {
|
||||
const { db, clientId, cmId, quote } = await world()
|
||||
const doc = await quote(clientId)
|
||||
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, doc.id)
|
||||
const audits = await db.all<{ after_json: string | null }>(
|
||||
`SELECT after_json FROM audit_log WHERE action='set_milestone'`,
|
||||
)
|
||||
expect(audits).toHaveLength(1)
|
||||
expect(audits[0]!.after_json).toContain(doc.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('milestone route accepts documentId', () => {
|
||||
const servers: { close: () => void }[] = []
|
||||
afterAll(() => { for (const s of servers) s.close() })
|
||||
|
||||
it('POST /client-modules/:id/milestones threads documentId through', async () => {
|
||||
const { db, clientId, cmId, quote } = await world()
|
||||
await createStaff(db, { email: 't@t.in', displayName: 'T', role: 'staff', password: 'password-1' })
|
||||
const app = express(); app.use(express.json()); app.locals['db'] = db
|
||||
app.use('/api', apiRouter(db, { keyHex: KEY }))
|
||||
const server = app.listen(0); servers.push(server)
|
||||
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
||||
const token = ((await (await fetch(`${base}/auth/login`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: 't@t.in', password: 'password-1' }),
|
||||
})).json()) as { token: string }).token
|
||||
const H = { 'content-type': 'application/json', authorization: `Bearer ${token}` }
|
||||
|
||||
const doc = await quote(clientId)
|
||||
const out = await (await fetch(`${base}/client-modules/${cmId}/milestones`, {
|
||||
method: 'POST', headers: H,
|
||||
body: JSON.stringify({ key: 'quotation_sent', done: true, documentId: doc.id }),
|
||||
})).json() as { ok: boolean; milestones: { key: string; documentId: string | null }[] }
|
||||
expect(out.ok).toBe(true)
|
||||
expect(out.milestones.find((m) => m.key === 'quotation_sent')!.documentId).toBe(doc.id)
|
||||
|
||||
// A foreign document is a 400, not a silent link.
|
||||
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
|
||||
const foreign = await quote(other.id)
|
||||
const bad = await fetch(`${base}/client-modules/${cmId}/milestones`, {
|
||||
method: 'POST', headers: H,
|
||||
body: JSON.stringify({ key: 'quotation_sent', done: true, documentId: foreign.id }),
|
||||
})
|
||||
expect(bad.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@ -1,113 +0,0 @@
|
||||
// apps/hq/test/milestone-link-preservation.test.ts — re-ticking an already-done step must not
|
||||
// wipe the payment / document link it already carries.
|
||||
//
|
||||
// The bug: setMilestone wrote `payment_id`/`document_id` as `done ? linked : null`, so any tick
|
||||
// that didn't resend the ids cleared them. bulkSetMilestone routes through setMilestone and
|
||||
// never passes ids at all (there is one key for many projects), so a single bulk re-tick — the
|
||||
// onboarding-cleanup tool — silently cut every project's step loose from the payment or
|
||||
// quotation it had been linked to one at a time. Unticking must still clear both.
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { openDb } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { createClient } from '../src/repos-clients'
|
||||
import { createModule, setPrice, assignModule } from '../src/repos-modules'
|
||||
import { createDraft } from '../src/repos-documents'
|
||||
import { recordPayment } from '../src/repos-payments'
|
||||
import { bulkSetMilestone, listProjectMilestones, setMilestone } from '../src/repos-milestones'
|
||||
|
||||
async function world() {
|
||||
const db = openDb(':memory:')
|
||||
await seedIfEmpty(db)
|
||||
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
|
||||
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
|
||||
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2020-01-01' })
|
||||
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||
const step = async (key: string) => (await listProjectMilestones(db, cm.id)).find((s) => s.key === key)!
|
||||
return { db, clientId: c.id, moduleId: m.id, cmId: cm.id, step }
|
||||
}
|
||||
|
||||
describe('bulk re-tick preserves the payment link (5a)', () => {
|
||||
it('tick with a payment link → bulk re-tick keeps it → bulk untick clears it', async () => {
|
||||
const { db, clientId, cmId, step } = await world()
|
||||
const pay = await recordPayment(db, 'u1', {
|
||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
||||
})
|
||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
||||
expect((await step('payment_received')).paymentId).toBe(pay.payment.id)
|
||||
|
||||
// The bulk tool carries no ids — the stored link must survive.
|
||||
const res = await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', true, '2026-06-01')
|
||||
expect(res.updated).toBe(1)
|
||||
const after = await step('payment_received')
|
||||
expect(after.done).toBe(true)
|
||||
expect(after.paymentId).toBe(pay.payment.id)
|
||||
|
||||
// Unticking still cuts the link loose.
|
||||
await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', false)
|
||||
const cleared = await step('payment_received')
|
||||
expect(cleared.done).toBe(false)
|
||||
expect(cleared.doneOn).toBeNull()
|
||||
expect(cleared.paymentId).toBeNull()
|
||||
})
|
||||
|
||||
it('a single re-tick that sends no ids keeps the link too', async () => {
|
||||
const { db, clientId, cmId, step } = await world()
|
||||
const pay = await recordPayment(db, 'u1', {
|
||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
||||
})
|
||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, '2026-06-01')
|
||||
const s = await step('payment_received')
|
||||
expect(s.doneOn).toBe('2026-06-01') // the date the caller did send still moves
|
||||
expect(s.paymentId).toBe(pay.payment.id)
|
||||
})
|
||||
|
||||
it('an explicit empty id is still an unlink, not a preserve', async () => {
|
||||
const { db, clientId, cmId, step } = await world()
|
||||
const pay = await recordPayment(db, 'u1', {
|
||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
||||
})
|
||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, '')
|
||||
expect((await step('payment_received')).paymentId).toBeNull()
|
||||
})
|
||||
|
||||
it('the preserved link is what the audit row reports', async () => {
|
||||
const { db, clientId, cmId } = await world()
|
||||
const pay = await recordPayment(db, 'u1', {
|
||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
||||
})
|
||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
||||
await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', true, '2026-06-01')
|
||||
const audits = await db.all<{ after_json: string | null }>(
|
||||
`SELECT after_json FROM audit_log WHERE action='set_milestone' ORDER BY id`,
|
||||
)
|
||||
expect(JSON.parse(audits.at(-1)!.after_json!)).toMatchObject({ paymentId: pay.payment.id })
|
||||
})
|
||||
})
|
||||
|
||||
describe('bulk re-tick preserves the document link (5a)', () => {
|
||||
it('tick with a quotation link → bulk re-tick keeps it → untick clears it', async () => {
|
||||
const { db, clientId, moduleId, cmId, step } = await world()
|
||||
const doc = await createDraft(db, 'u1', {
|
||||
docType: 'QUOTATION', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }],
|
||||
})
|
||||
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, '2026-05-01', undefined, doc.id)
|
||||
expect((await step('quotation_sent')).documentId).toBe(doc.id)
|
||||
|
||||
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', true, '2026-06-01')
|
||||
expect((await step('quotation_sent')).documentId).toBe(doc.id)
|
||||
|
||||
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', false)
|
||||
expect((await step('quotation_sent')).documentId).toBeNull()
|
||||
})
|
||||
|
||||
it('a step that was never done still starts with no link (nothing to preserve)', async () => {
|
||||
const { db, cmId, step } = await world()
|
||||
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', true, '2026-06-01')
|
||||
const s = await step('quotation_sent')
|
||||
expect(s.done).toBe(true)
|
||||
expect(s.documentId).toBeNull()
|
||||
expect(s.paymentId).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -1,58 +1,30 @@
|
||||
// apps/hq/test/milestones-templates.test.ts — composed (front + per-module tail) onboarding
|
||||
// template resolution.
|
||||
// apps/hq/test/milestones-templates.test.ts — per-module onboarding template resolution.
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { openDb, type DB } from '../src/db'
|
||||
import { milestoneTemplate, FRONT_FALLBACK, TAIL_FALLBACK } from '../src/repos-milestones'
|
||||
import { milestoneTemplate } from '../src/repos-milestones'
|
||||
|
||||
describe('composed milestone templates (front + tail)', () => {
|
||||
describe('per-module milestone templates', () => {
|
||||
let db: DB
|
||||
beforeEach(() => { db = openDb(':memory:') })
|
||||
|
||||
it('falls back to the code FRONT_FALLBACK + TAIL_FALLBACK when nothing is configured', async () => {
|
||||
expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
|
||||
expect(await milestoneTemplate(db)).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
|
||||
})
|
||||
|
||||
it('starts with the 4 front keys then the SMS tail', async () => {
|
||||
const keys = (await milestoneTemplate(db, 'SMS')).map((e) => e.key)
|
||||
expect(keys.slice(0, 4)).toEqual(FRONT_FALLBACK.map((f) => f.key))
|
||||
})
|
||||
|
||||
it('uses the configured front for every module and with no moduleCode', async () => {
|
||||
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template.front', ?)`,
|
||||
JSON.stringify([{ key: 'f1', label: 'F1' }]))
|
||||
expect((await milestoneTemplate(db, 'SMS'))[0]).toEqual({ key: 'f1', label: 'F1' })
|
||||
expect((await milestoneTemplate(db))[0]).toEqual({ key: 'f1', label: 'F1' })
|
||||
})
|
||||
|
||||
it('tail: falls back to the global tail template when no module-specific one exists', async () => {
|
||||
it('falls back to the global template when no module-specific one exists', async () => {
|
||||
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
|
||||
JSON.stringify([{ key: 'a', label: 'A' }]))
|
||||
expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, { key: 'a', label: 'A' }])
|
||||
expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'a', label: 'A' }])
|
||||
})
|
||||
|
||||
it('tail: prefers the module-specific template over the global one', async () => {
|
||||
it('prefers the module-specific template over the global one', async () => {
|
||||
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
|
||||
JSON.stringify([{ key: 'a', label: 'A' }]))
|
||||
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`,
|
||||
JSON.stringify([{ key: 'doc', label: 'Document collection' }]))
|
||||
expect(await milestoneTemplate(db, 'SMS'))
|
||||
.toEqual([...FRONT_FALLBACK, { key: 'doc', label: 'Document collection' }])
|
||||
// A different module still gets the global tail.
|
||||
expect(await milestoneTemplate(db, 'RTGS')).toEqual([...FRONT_FALLBACK, { key: 'a', label: 'A' }])
|
||||
})
|
||||
|
||||
it('with no moduleCode: front + global tail when set, else TAIL_FALLBACK', async () => {
|
||||
expect(await milestoneTemplate(db)).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
|
||||
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
|
||||
JSON.stringify([{ key: 'a', label: 'A' }]))
|
||||
expect(await milestoneTemplate(db)).toEqual([...FRONT_FALLBACK, { key: 'a', label: 'A' }])
|
||||
expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'doc', label: 'Document collection' }])
|
||||
// A different module still gets the global one.
|
||||
expect(await milestoneTemplate(db, 'RTGS')).toEqual([{ key: 'a', label: 'A' }])
|
||||
})
|
||||
|
||||
it('a malformed or empty per-module tail setting is treated as absent', async () => {
|
||||
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`, 'not json')
|
||||
expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
|
||||
await db.run(`UPDATE setting SET value=? WHERE key='project.milestone_template:SMS'`, '[]')
|
||||
expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
|
||||
it('falls back to the code default when nothing is seeded', async () => {
|
||||
const t = await milestoneTemplate(db, 'SMS')
|
||||
expect(t.map((e) => e.key)).toContain('go_live')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,224 +0,0 @@
|
||||
// apps/hq/test/onboarding-template-routes.test.ts — final whole-branch review FIX 4: the
|
||||
// owner onboarding-template editor surface (normalizeTemplateSteps / setFrontTemplate /
|
||||
// setModuleTail / getFrontTemplate / getModuleTail + the four routes) had no direct test
|
||||
// coverage. Also exercises the cross-half (front <-> tail) key collision rejection added by
|
||||
// FIX 1, at both the repo level and over HTTP.
|
||||
import express from 'express'
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { openDb, type DB } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { apiRouter } from '../src/api'
|
||||
import { createStaff } from '../src/auth'
|
||||
import { createModule } from '../src/repos-modules'
|
||||
import {
|
||||
normalizeTemplateSteps, setFrontTemplate, setModuleTail, getFrontTemplate, getModuleTail,
|
||||
FRONT_FALLBACK, TAIL_FALLBACK,
|
||||
} from '../src/repos-milestones'
|
||||
|
||||
const KEY = '33'.repeat(32)
|
||||
|
||||
describe('normalizeTemplateSteps (pure validation)', () => {
|
||||
it('rejects a non-array or empty body', () => {
|
||||
expect(() => normalizeTemplateSteps(undefined)).toThrow(/at least one/i)
|
||||
expect(() => normalizeTemplateSteps('nope')).toThrow(/at least one/i)
|
||||
expect(() => normalizeTemplateSteps([])).toThrow(/at least one/i)
|
||||
})
|
||||
|
||||
it('rejects a blank label', () => {
|
||||
expect(() => normalizeTemplateSteps([{ label: ' ' }])).toThrow(/label/i)
|
||||
expect(() => normalizeTemplateSteps([{ key: 'a', label: 'A' }, { label: '' }])).toThrow(/label/i)
|
||||
})
|
||||
|
||||
it('rejects a duplicate explicit key', () => {
|
||||
expect(() => normalizeTemplateSteps([
|
||||
{ key: 'dup', label: 'First' }, { key: 'dup', label: 'Second' },
|
||||
])).toThrow(/Duplicate step key/)
|
||||
})
|
||||
|
||||
it('derives a key by slugifying the label when none is given, bumping on collision', () => {
|
||||
const out = normalizeTemplateSteps([
|
||||
{ label: 'Site Visit!' }, { label: 'site visit' }, { label: 'Site -- Visit' },
|
||||
])
|
||||
expect(out).toEqual([
|
||||
{ key: 'site_visit', label: 'Site Visit!' },
|
||||
{ key: 'site_visit_2', label: 'site visit' },
|
||||
{ key: 'site_visit_3', label: 'Site -- Visit' },
|
||||
])
|
||||
})
|
||||
|
||||
it('a key derived from a label collides with a later EXPLICIT key of the same value -> rejected', () => {
|
||||
expect(() => normalizeTemplateSteps([{ label: 'Go Live' }, { key: 'go_live', label: 'Go-live (explicit)' }]))
|
||||
.toThrow(/Duplicate step key/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setFrontTemplate / setModuleTail / getFrontTemplate / getModuleTail (repo level)', () => {
|
||||
it('getFrontTemplate / getModuleTail fall back to the code defaults on an empty DB', async () => {
|
||||
const db = openDb(':memory:')
|
||||
expect(await getFrontTemplate(db)).toEqual(FRONT_FALLBACK)
|
||||
expect(await getModuleTail(db, 'SMS')).toEqual(TAIL_FALLBACK)
|
||||
})
|
||||
|
||||
it('setFrontTemplate rejects a key colliding with the default tail', async () => {
|
||||
const db = openDb(':memory:')
|
||||
await expect(setFrontTemplate(db, 'u1', [{ key: 'installed', label: 'Site installed' }]))
|
||||
.rejects.toThrow(/used by both the front/i)
|
||||
})
|
||||
|
||||
it('setFrontTemplate rejects a key colliding with an already-configured per-module tail', async () => {
|
||||
const db = openDb(':memory:')
|
||||
await setModuleTail(db, 'u1', 'SMS', [{ key: 'kickoff', label: 'Kickoff call' }])
|
||||
await expect(setFrontTemplate(db, 'u1', [{ key: 'kickoff', label: 'Kickoff' }]))
|
||||
.rejects.toThrow(/SMS/)
|
||||
// The front setting was never written — still the fallback.
|
||||
expect(await getFrontTemplate(db)).toEqual(FRONT_FALLBACK)
|
||||
})
|
||||
|
||||
it('setModuleTail rejects a key colliding with the shared front', async () => {
|
||||
const db = openDb(':memory:')
|
||||
await expect(setModuleTail(db, 'u1', 'SMS', [{ key: 'enquiry', label: 'Enquiry (dup)' }]))
|
||||
.rejects.toThrow(/front/i)
|
||||
})
|
||||
|
||||
it('setModuleTail rejects a key colliding with a CUSTOM front (not just the code fallback)', async () => {
|
||||
const db = openDb(':memory:')
|
||||
await setFrontTemplate(db, 'u1', [{ key: 'lead_in', label: 'Lead in' }])
|
||||
await expect(setModuleTail(db, 'u1', 'SMS', [{ key: 'lead_in', label: 'Also lead in' }]))
|
||||
.rejects.toThrow(/front/i)
|
||||
})
|
||||
|
||||
it('a non-colliding edit round-trips through the getters and is audited', async () => {
|
||||
const db = openDb(':memory:')
|
||||
const front = [{ key: 'lead_in', label: 'Lead in' }]
|
||||
const tail = [{ key: 'kickoff', label: 'Kickoff call' }, { key: 'wrapup', label: 'Wrap up' }]
|
||||
await setFrontTemplate(db, 'u1', front)
|
||||
await setModuleTail(db, 'u1', 'SMS', tail)
|
||||
expect(await getFrontTemplate(db)).toEqual(front)
|
||||
expect(await getModuleTail(db, 'SMS')).toEqual(tail)
|
||||
// A different, unconfigured module still gets the code fallback tail.
|
||||
expect(await getModuleTail(db, 'RTGS')).toEqual(TAIL_FALLBACK)
|
||||
const audited = await db.all<{ n: number }>(
|
||||
`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'project.milestone_template%'`,
|
||||
)
|
||||
expect((audited[0] as unknown as { n: number }).n).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onboarding-template routes (owner-only editor)', () => {
|
||||
const servers: { close: () => void }[] = []
|
||||
afterAll(() => { for (const s of servers) s.close() })
|
||||
|
||||
async function httpWorld(): Promise<{ db: DB; base: string; ownerH: Record<string, string>; staffH: Record<string, string> }> {
|
||||
const db = openDb(':memory:')
|
||||
await seedIfEmpty(db)
|
||||
await createStaff(db, { email: 'owner2@t.in', displayName: 'Owner2', role: 'owner', password: 'owner-pass-1' })
|
||||
await createStaff(db, { email: 'staff@t.in', displayName: 'Staff', role: 'staff', password: 'staff-pass-1' })
|
||||
await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
|
||||
// seedIfEmpty pre-seeds a `project.milestone_template:SMS` setting (the shipped SMS
|
||||
// pipeline), so use an UNconfigured module for tests that assert the code TAIL_FALLBACK.
|
||||
await createModule(db, 'u1', { code: 'WIDGET', name: 'Unconfigured Widget' })
|
||||
const app = express(); app.use(express.json()); app.locals['db'] = db
|
||||
app.use('/api', apiRouter(db, { keyHex: KEY }))
|
||||
const server = app.listen(0); servers.push(server)
|
||||
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
||||
const login = async (email: string, password: string): Promise<Record<string, string>> => {
|
||||
const token = ((await (await fetch(`${base}/auth/login`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})).json()) as { token: string }).token
|
||||
return { 'content-type': 'application/json', authorization: `Bearer ${token}` }
|
||||
}
|
||||
return { db, base, ownerH: await login('owner2@t.in', 'owner-pass-1'), staffH: await login('staff@t.in', 'staff-pass-1') }
|
||||
}
|
||||
|
||||
it('GET /onboarding-template/front: owner only, serves the fallback by default', async () => {
|
||||
const { base, ownerH, staffH } = await httpWorld()
|
||||
const denied = await fetch(`${base}/onboarding-template/front`, { headers: staffH })
|
||||
expect(denied.status).toBe(403)
|
||||
const out = await (await fetch(`${base}/onboarding-template/front`, { headers: ownerH })).json() as
|
||||
{ ok: boolean; steps: { key: string; label: string }[] }
|
||||
expect(out.ok).toBe(true)
|
||||
expect(out.steps).toEqual(FRONT_FALLBACK)
|
||||
})
|
||||
|
||||
it('PUT /onboarding-template/front: owner only; rejects blank label / duplicate key; round-trips on GET', async () => {
|
||||
const { base, ownerH, staffH } = await httpWorld()
|
||||
const denied = await fetch(`${base}/onboarding-template/front`, {
|
||||
method: 'PUT', headers: staffH, body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }] }),
|
||||
})
|
||||
expect(denied.status).toBe(403)
|
||||
|
||||
const blank = await fetch(`${base}/onboarding-template/front`, {
|
||||
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'a', label: ' ' }] }),
|
||||
})
|
||||
expect(blank.status).toBe(400)
|
||||
|
||||
const dup = await fetch(`${base}/onboarding-template/front`, {
|
||||
method: 'PUT', headers: ownerH,
|
||||
body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }, { key: 'a', label: 'A2' }] }),
|
||||
})
|
||||
expect(dup.status).toBe(400)
|
||||
|
||||
const newFront = [{ label: 'Lead In!' }, { key: 'confirmed', label: 'Confirmed' }]
|
||||
const put = await fetch(`${base}/onboarding-template/front`, {
|
||||
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: newFront }),
|
||||
})
|
||||
expect(put.status).toBe(200)
|
||||
const putBody = await put.json() as { steps: { key: string; label: string }[] }
|
||||
expect(putBody.steps[0]).toEqual({ key: 'lead_in', label: 'Lead In!' })
|
||||
|
||||
const reGet = await (await fetch(`${base}/onboarding-template/front`, { headers: ownerH })).json() as
|
||||
{ steps: { key: string; label: string }[] }
|
||||
expect(reGet.steps).toEqual(putBody.steps)
|
||||
})
|
||||
|
||||
it('GET/PUT /modules/:code/onboarding-template: 404 on an unknown module code', async () => {
|
||||
const { base, ownerH } = await httpWorld()
|
||||
const getMissing = await fetch(`${base}/modules/NOPE/onboarding-template`, { headers: ownerH })
|
||||
expect(getMissing.status).toBe(404)
|
||||
const putMissing = await fetch(`${base}/modules/NOPE/onboarding-template`, {
|
||||
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }] }),
|
||||
})
|
||||
expect(putMissing.status).toBe(404)
|
||||
})
|
||||
|
||||
it('GET/PUT /modules/:code/onboarding-template: owner only, round-trips a valid edit', async () => {
|
||||
const { base, ownerH, staffH } = await httpWorld()
|
||||
const denied = await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: staffH })
|
||||
expect(denied.status).toBe(403)
|
||||
|
||||
const before = await (await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: ownerH })).json() as
|
||||
{ steps: { key: string; label: string }[] }
|
||||
expect(before.steps).toEqual(TAIL_FALLBACK)
|
||||
|
||||
const newTail = [{ key: 'kickoff', label: 'Kickoff call' }, { key: 'wrapup', label: 'Wrap up' }]
|
||||
const put = await fetch(`${base}/modules/WIDGET/onboarding-template`, {
|
||||
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: newTail }),
|
||||
})
|
||||
expect(put.status).toBe(200)
|
||||
|
||||
const after = await (await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: ownerH })).json() as
|
||||
{ steps: { key: string; label: string }[] }
|
||||
expect(after.steps).toEqual(newTail)
|
||||
})
|
||||
|
||||
it('cross-half collision (FIX 1) is rejected with 400 over HTTP, both directions', async () => {
|
||||
const { base, ownerH } = await httpWorld()
|
||||
// Front step whose key collides with the (fallback) SMS tail's 'installed'... use the
|
||||
// module tail directly since SMS starts from TAIL_FALLBACK (has 'installed').
|
||||
const frontCollide = await fetch(`${base}/onboarding-template/front`, {
|
||||
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'installed', label: 'Installed' }] }),
|
||||
})
|
||||
expect(frontCollide.status).toBe(400)
|
||||
const frontCollideBody = await frontCollide.json() as { ok: boolean; error: string }
|
||||
expect(frontCollideBody.error).toMatch(/used by both the front/i)
|
||||
|
||||
// Tail step whose key collides with the shared front's 'enquiry'.
|
||||
const tailCollide = await fetch(`${base}/modules/SMS/onboarding-template`, {
|
||||
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'enquiry', label: 'Enquiry (dup)' }] }),
|
||||
})
|
||||
expect(tailCollide.status).toBe(400)
|
||||
const tailCollideBody = await tailCollide.json() as { ok: boolean; error: string }
|
||||
expect(tailCollideBody.error).toMatch(/front/i)
|
||||
})
|
||||
})
|
||||
@ -1,78 +0,0 @@
|
||||
// apps/hq/test/projects-stalled-route.test.ts — pre-ship audit FIX B: GET /projects/stalled
|
||||
// had no try/catch, and a caller-supplied `?days=` flowed straight into
|
||||
// `new Date(...).toISOString()`, which throws a RangeError on an out-of-range/NaN value.
|
||||
// On Express 4 + Node 22 an unhandled rejection from an async route handler crashes the
|
||||
// whole process — ANY authenticated role (including plain staff) could take the console
|
||||
// down with one GET. `days` is now validated and the handler wrapped like its siblings.
|
||||
import express from 'express'
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { openDb } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { apiRouter } from '../src/api'
|
||||
import { createStaff } from '../src/auth'
|
||||
|
||||
describe('GET /projects/stalled — hostile ?days= must not crash the server', () => {
|
||||
const servers: { close: () => void }[] = []
|
||||
afterAll(() => { for (const s of servers) s.close() })
|
||||
|
||||
async function httpWorld(): Promise<{ base: string; staffH: Record<string, string> }> {
|
||||
const db = openDb(':memory:')
|
||||
await seedIfEmpty(db)
|
||||
await createStaff(db, { email: 'staff-stalled@t.in', displayName: 'Staff', role: 'staff', password: 'staff-pass-1' })
|
||||
const app = express(); app.use(express.json()); app.locals['db'] = db
|
||||
app.use('/api', apiRouter(db))
|
||||
const server = app.listen(0); servers.push(server)
|
||||
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
||||
const token = ((await (await fetch(`${base}/auth/login`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'staff-stalled@t.in', password: 'staff-pass-1' }),
|
||||
})).json()) as { token: string }).token
|
||||
return { base, staffH: { authorization: `Bearer ${token}` } }
|
||||
}
|
||||
|
||||
it('a plain request (no ?days=) works — falls back to the configured default', async () => {
|
||||
const { base, staffH } = await httpWorld()
|
||||
const res = await fetch(`${base}/projects/stalled`, { headers: staffH })
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as { ok: boolean; projects: unknown[]; unknownAgeExcluded: number }
|
||||
expect(body.ok).toBe(true)
|
||||
expect(Array.isArray(body.projects)).toBe(true)
|
||||
// FIX 4: the count of pending projects excluded for unprovable age travels alongside the
|
||||
// list itself now, so the UI can surface "N of unknown age" instead of a silent drop.
|
||||
expect(typeof body.unknownAgeExcluded).toBe('number')
|
||||
})
|
||||
|
||||
it('a sane ?days= still works normally', async () => {
|
||||
const { base, staffH } = await httpWorld()
|
||||
const res = await fetch(`${base}/projects/stalled?days=45`, { headers: staffH })
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as { ok: boolean }).ok).toBe(true)
|
||||
})
|
||||
|
||||
const hostileValues = [
|
||||
'1e300', // finite in JS but overflows Date math -> used to throw RangeError
|
||||
'-99999999999999', // huge negative -> overflows the other direction
|
||||
'3.5', // not an integer
|
||||
'abc', // NaN
|
||||
'NaN',
|
||||
'Infinity',
|
||||
'-Infinity',
|
||||
'99999999999999999999', // way outside the sane 0-3650 bound
|
||||
'-1', // out of bound (negative)
|
||||
]
|
||||
|
||||
for (const hostile of hostileValues) {
|
||||
it(`?days=${hostile} is rejected with a 4xx, never throws / crashes the server`, async () => {
|
||||
const { base, staffH } = await httpWorld()
|
||||
const res = await fetch(`${base}/projects/stalled?days=${encodeURIComponent(hostile)}`, { headers: staffH })
|
||||
expect(res.status).toBeGreaterThanOrEqual(400)
|
||||
expect(res.status).toBeLessThan(500)
|
||||
const body = await res.json() as { ok: boolean }
|
||||
expect(body.ok).toBe(false)
|
||||
// The server process must still be alive for the NEXT request — the original bug's
|
||||
// unhandled RangeError rejection killed the whole process.
|
||||
const health = await fetch(`${base}/health`)
|
||||
expect(health.status).toBe(200)
|
||||
})
|
||||
}
|
||||
})
|
||||
@ -1,161 +0,0 @@
|
||||
// apps/hq/test/ticket-types.test.ts — Client Detail redesign task 6: ticket classification.
|
||||
// The list is config over code (the `ticket.types` setting, code fallback); tickets carry
|
||||
// a `type`, the desk filters by it.
|
||||
import express from 'express'
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { openDb, type DB } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { apiRouter } from '../src/api'
|
||||
import { createStaff } from '../src/auth'
|
||||
import { createClient } from '../src/repos-clients'
|
||||
import { setSetting } from '../src/repos-reminders'
|
||||
import {
|
||||
createTicket, updateTicket, listTickets, ticketTypes, TICKET_TYPE_FALLBACK,
|
||||
} from '../src/repos-tickets'
|
||||
|
||||
const KEY = '11'.repeat(32)
|
||||
|
||||
async function world() {
|
||||
const db = openDb(':memory:')
|
||||
await seedIfEmpty(db)
|
||||
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
|
||||
return { db, c }
|
||||
}
|
||||
|
||||
describe('ticket type list (config over code)', () => {
|
||||
it('seeds `ticket.types` on first boot, matching the code fallback', async () => {
|
||||
const { db } = await world()
|
||||
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='ticket.types'`)
|
||||
expect(row).toBeDefined()
|
||||
expect(JSON.parse(row!.value)).toEqual(TICKET_TYPE_FALLBACK)
|
||||
})
|
||||
|
||||
it('the resolver prefers the setting over the code fallback', async () => {
|
||||
const { db } = await world()
|
||||
await setSetting(db, 'u1', 'ticket.types', JSON.stringify([
|
||||
{ key: 'service', label: 'Service call' }, { key: 'audit', label: 'Audit visit' },
|
||||
]))
|
||||
expect(await ticketTypes(db)).toEqual([
|
||||
{ key: 'service', label: 'Service call' }, { key: 'audit', label: 'Audit visit' },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the code list when the setting is missing, empty or unparseable', async () => {
|
||||
const { db } = await world()
|
||||
await db.run(`DELETE FROM setting WHERE key='ticket.types'`)
|
||||
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
|
||||
await setSetting(db, 'u1', 'ticket.types', '[]')
|
||||
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
|
||||
await setSetting(db, 'u1', 'ticket.types', 'not json at all')
|
||||
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
|
||||
await setSetting(db, 'u1', 'ticket.types', JSON.stringify([{ label: 'no key' }]))
|
||||
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ticket.type on create / update / list', () => {
|
||||
it("defaults to 'service' when the caller sends no type", async () => {
|
||||
const { db, c } = await world()
|
||||
const t = await createTicket(db, 'u1', { clientId: c.id, description: 'no type given' })
|
||||
expect(t.type).toBe('service')
|
||||
})
|
||||
|
||||
it('stores a configured type and rejects an unknown one', async () => {
|
||||
const { db, c } = await world()
|
||||
const t = await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'renewal' })
|
||||
expect(t.type).toBe('amc')
|
||||
await expect(createTicket(db, 'u1', { clientId: c.id, type: 'nonsense' })).rejects.toThrow(/type/i)
|
||||
})
|
||||
|
||||
it('updates the type (audited) and rejects an unknown one', async () => {
|
||||
const { db, c } = await world()
|
||||
const t = await createTicket(db, 'u1', { clientId: c.id, description: 'reclassify me' })
|
||||
const after = await updateTicket(db, 'u1', t.id, { type: 'modification' })
|
||||
expect(after.type).toBe('modification')
|
||||
await expect(updateTicket(db, 'u1', t.id, { type: 'nonsense' })).rejects.toThrow(/type/i)
|
||||
// still 'modification' — the rejected patch wrote nothing
|
||||
expect((await listTickets(db, { clientId: c.id })).tickets[0]!.type).toBe('modification')
|
||||
})
|
||||
|
||||
it('filters the list by type; a blank/omitted filter returns every type', async () => {
|
||||
const { db, c } = await world()
|
||||
await createTicket(db, 'u1', { clientId: c.id, type: 'service', description: 'a' })
|
||||
await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'b' })
|
||||
await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'c' })
|
||||
expect((await listTickets(db, { type: 'amc' })).total).toBe(2)
|
||||
expect((await listTickets(db, { type: 'service' })).total).toBe(1)
|
||||
expect((await listTickets(db, { type: 'modification' })).total).toBe(0)
|
||||
expect((await listTickets(db, { type: '' })).total).toBe(3)
|
||||
expect((await listTickets(db, {})).total).toBe(3)
|
||||
})
|
||||
|
||||
it('an existing (pre-migration) row reads back as the default type', async () => {
|
||||
const { db, c } = await world()
|
||||
// Insert the way the APEX importer does — without naming the new column.
|
||||
await db.run(
|
||||
`INSERT INTO ticket (id, client_id, kind, description, opened_on, created_by, created_at, source)
|
||||
VALUES ('legacy-1', ?, 'MODIFICATION', 'imported', '2025-01-01', 'u1', '2025-01-01T00:00:00.000Z', 'apex')`,
|
||||
c.id,
|
||||
)
|
||||
const listed = await listTickets(db, { clientId: c.id })
|
||||
expect(listed.tickets[0]!.type).toBe('service')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ticket type routes', () => {
|
||||
const servers: { close: () => void }[] = []
|
||||
afterAll(() => { for (const s of servers) s.close() })
|
||||
|
||||
async function httpWorld(): Promise<{ db: DB; clientId: string; base: string; H: Record<string, string> }> {
|
||||
const { db, c } = await world()
|
||||
await createStaff(db, { email: 't@t.in', displayName: 'T', role: 'staff', password: 'password-1' })
|
||||
const app = express(); app.use(express.json()); app.locals['db'] = db
|
||||
app.use('/api', apiRouter(db, { keyHex: KEY }))
|
||||
const server = app.listen(0); servers.push(server)
|
||||
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
||||
const token = ((await (await fetch(`${base}/auth/login`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: 't@t.in', password: 'password-1' }),
|
||||
})).json()) as { token: string }).token
|
||||
return { db, clientId: c.id, base, H: { 'content-type': 'application/json', authorization: `Bearer ${token}` } }
|
||||
}
|
||||
|
||||
it('GET /tickets/types serves the configured list', async () => {
|
||||
const { base, H } = await httpWorld()
|
||||
const out = await (await fetch(`${base}/tickets/types`, { headers: H })).json() as
|
||||
{ ok: boolean; types: { key: string; label: string }[] }
|
||||
expect(out.ok).toBe(true)
|
||||
expect(out.types).toEqual(TICKET_TYPE_FALLBACK)
|
||||
})
|
||||
|
||||
it('POST /tickets accepts a type, and GET /tickets?type= filters on it', async () => {
|
||||
const { clientId, base, H } = await httpWorld()
|
||||
for (const type of ['service', 'amc', 'amc']) {
|
||||
const created = await (await fetch(`${base}/tickets`, {
|
||||
method: 'POST', headers: H, body: JSON.stringify({ clientId, type, description: type }),
|
||||
})).json() as { ok: boolean; ticket: { type: string } }
|
||||
expect(created.ticket.type).toBe(type)
|
||||
}
|
||||
const amc = await (await fetch(`${base}/tickets?type=amc`, { headers: H })).json() as
|
||||
{ total: number; tickets: { type: string }[] }
|
||||
expect(amc.total).toBe(2)
|
||||
expect(amc.tickets.every((t) => t.type === 'amc')).toBe(true)
|
||||
const all = await (await fetch(`${base}/tickets?type=`, { headers: H })).json() as { total: number }
|
||||
expect(all.total).toBe(3)
|
||||
})
|
||||
|
||||
it('PATCH /tickets/:id reclassifies; an unknown type is a 400', async () => {
|
||||
const { clientId, base, H } = await httpWorld()
|
||||
const created = await (await fetch(`${base}/tickets`, {
|
||||
method: 'POST', headers: H, body: JSON.stringify({ clientId, description: 'x' }),
|
||||
})).json() as { ticket: { id: string } }
|
||||
const patched = await (await fetch(`${base}/tickets/${created.ticket.id}`, {
|
||||
method: 'PATCH', headers: H, body: JSON.stringify({ type: 'module' }),
|
||||
})).json() as { ticket: { type: string } }
|
||||
expect(patched.ticket.type).toBe('module')
|
||||
const bad = await fetch(`${base}/tickets/${created.ticket.id}`, {
|
||||
method: 'PATCH', headers: H, body: JSON.stringify({ type: 'nonsense' }),
|
||||
})
|
||||
expect(bad.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* SiMS HQ database backup (ops slice). Dumps the Postgres database to a timestamped,
|
||||
* compressed file under ./backups (or $HQ_BACKUP_DIR), then rotates to the newest N.
|
||||
*
|
||||
* DATABASE_URL=postgres://user:pass@host:5432/db node scripts/backup.mjs
|
||||
* npm run backup (from apps/hq — passes DATABASE_URL through)
|
||||
*
|
||||
* Restore a dump with:
|
||||
* pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" backups/hq-<ts>.dump
|
||||
*
|
||||
* SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext
|
||||
* logins. Keep the backups/ directory access-controlled and off any shared drive; it is
|
||||
* gitignored so it can never be committed.
|
||||
*
|
||||
* Env: DATABASE_URL (required), HQ_BACKUP_DIR (default ./backups), HQ_BACKUP_KEEP (default 14).
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const url = process.env.DATABASE_URL
|
||||
if (!url || url.trim() === '') {
|
||||
console.error('DATABASE_URL is not set. Run: DATABASE_URL=postgres://… node scripts/backup.mjs')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/** Locate pg_dump: PATH first, then a standard Windows PostgreSQL install. */
|
||||
function findPgDump() {
|
||||
try {
|
||||
execFileSync(process.platform === 'win32' ? 'where' : 'which', ['pg_dump'], { stdio: 'ignore' })
|
||||
return 'pg_dump'
|
||||
} catch { /* not on PATH */ }
|
||||
if (process.platform === 'win32') {
|
||||
const base = 'C:/Program Files/PostgreSQL'
|
||||
try {
|
||||
for (const v of readdirSync(base).sort().reverse()) {
|
||||
const cand = join(base, v, 'bin', 'pg_dump.exe')
|
||||
if (existsSync(cand)) return cand
|
||||
}
|
||||
} catch { /* no install dir */ }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const pgDump = findPgDump()
|
||||
if (pgDump === null) {
|
||||
console.error('pg_dump not found. Install the PostgreSQL client tools, or add pg_dump to PATH.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const OUT = process.env.HQ_BACKUP_DIR || join(process.cwd(), 'backups')
|
||||
const KEEP = Math.max(1, Number(process.env.HQ_BACKUP_KEEP || 14))
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
|
||||
const d = new Date()
|
||||
const p2 = (n) => String(n).padStart(2, '0')
|
||||
const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`
|
||||
const file = join(OUT, `hq-${ts}.dump`)
|
||||
|
||||
// -Fc = custom, compressed, restorable with pg_restore. --no-owner keeps it portable.
|
||||
try {
|
||||
execFileSync(pgDump, ['-Fc', '--no-owner', '-f', file, url], { stdio: ['ignore', 'inherit', 'inherit'] })
|
||||
} catch (e) {
|
||||
console.error(`pg_dump failed: ${e instanceof Error ? e.message : String(e)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const kb = (statSync(file).size / 1024).toFixed(1)
|
||||
console.log(`✓ backup written: ${file} (${kb} KB)`)
|
||||
|
||||
// Rotation: keep the newest KEEP hq-YYYYMMDD-HHMMSS.dump files.
|
||||
const dumps = readdirSync(OUT).filter((f) => /^hq-\d{8}-\d{6}\.dump$/.test(f)).sort()
|
||||
for (const f of dumps.slice(0, Math.max(0, dumps.length - KEEP))) {
|
||||
unlinkSync(join(OUT, f))
|
||||
console.log(` rotated out: ${f}`)
|
||||
}
|
||||
console.log(`retained ${Math.min(dumps.length, KEEP)} backup(s) in ${OUT} (HQ_BACKUP_KEEP=${KEEP})`)
|
||||
Loading…
Reference in New Issue