Compare commits
50 Commits
feat/clien
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
7e139d91b8 | 1 day ago |
|
|
8456e45206 | 1 day ago |
|
|
12e95e7be4 | 1 day ago |
|
|
551c9e3d30 | 1 day ago |
|
|
a267cd07e6 | 1 day ago |
|
|
23b0b64de2 | 1 day ago |
|
|
0adce5844b | 1 day ago |
|
|
4c1514c315 | 1 day ago |
|
|
77f135f0e2 | 1 day ago |
|
|
367fc0324e | 1 day ago |
|
|
b32f7c32c8 | 1 day ago |
|
|
1717900e02 | 1 day ago |
|
|
aa541270d6 | 1 day ago |
|
|
4de152a7f4 | 1 day ago |
|
|
591d0e9b59 | 1 day ago |
|
|
ee0367dfb5 | 1 day ago |
|
|
6f50799d3c | 1 day ago |
|
|
b1e1420b4c | 1 day ago |
|
|
a0225b2ba3 | 1 day ago |
|
|
f66bab122b | 1 day ago |
|
|
0c3f6c2e3a | 1 day ago |
|
|
9457b0f145 | 1 day ago |
|
|
e8baa1111e | 1 day ago |
|
|
04aef0aa36 | 1 day ago |
|
|
31335cd4ad | 1 day ago |
|
|
0bcbf8d410 | 1 day ago |
|
|
b146de990f | 1 day ago |
|
|
4ee8f17b37 | 1 day ago |
|
|
e8e2a202c1 | 1 day ago |
|
|
7494f53e2f | 1 day ago |
|
|
ac16d8c896 | 1 day ago |
|
|
3ac4e2a47d | 1 day ago |
|
|
7838ced6c2 | 1 day ago |
|
|
357735e04d | 1 day ago |
|
|
88a19f154e | 1 day ago |
|
|
83633c86ee | 1 day ago |
|
|
d6faf0d5bd | 1 day ago |
|
|
4e747427d2 | 1 day ago |
|
|
45cfb83d03 | 1 day ago |
|
|
371cdb1c69 | 2 days ago |
|
|
db8ec9c546 | 2 days ago |
|
|
0050f73a70 | 2 days ago |
|
|
025b334b09 | 2 days ago |
|
|
78ed947422 | 2 days ago |
|
|
f1a7eac9dd | 2 days ago |
|
|
e8f00503a4 | 2 days ago |
|
|
e5ed3517dc | 2 days ago |
|
|
343fa94efd | 2 days ago |
|
|
8c653eb41a | 2 days ago |
|
|
a9e5aa79d9 | 2 days ago |
@ -0,0 +1 @@
|
|||||||
|
sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"adoptedAt": 1784565886864,
|
||||||
|
"championId": "sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319",
|
||||||
|
"manifest": {
|
||||||
|
"schema": "ruflo.proven-config/v1",
|
||||||
|
"policy": {
|
||||||
|
"ref": "sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319",
|
||||||
|
"value": {
|
||||||
|
"alpha": 0.3,
|
||||||
|
"subjectWeight": 1,
|
||||||
|
"mmrLambda": 0.5,
|
||||||
|
"bodyWeight": 1.5,
|
||||||
|
"typePenaltyFactor": 0.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layer": "framework/node-cli",
|
||||||
|
"compatibility": {
|
||||||
|
"ruflo": ">=3.24.0"
|
||||||
|
},
|
||||||
|
"benchmark": {
|
||||||
|
"corpus": "ADR-081-labelled-v1",
|
||||||
|
"corpusHash": "sha256:2f700b5c363e20a3bd88ce2bc9b87bbbbaa61732c6177894c6ec37890f888982"
|
||||||
|
},
|
||||||
|
"receipt": {
|
||||||
|
"heldOutDelta": 0.07381404928570845,
|
||||||
|
"redblue": "PASS",
|
||||||
|
"drift": 0,
|
||||||
|
"canary": {
|
||||||
|
"rollbackRate": 0,
|
||||||
|
"latencyP95": 244.612458000076,
|
||||||
|
"costPerTask": 0
|
||||||
|
},
|
||||||
|
"receiptCoverage": 1
|
||||||
|
},
|
||||||
|
"platform": [
|
||||||
|
"linux",
|
||||||
|
"macOS",
|
||||||
|
"windows"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"previous": ""
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
// 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) })
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
// Go-live data correction: the earlier migrate-sqlite-to-pg.ts run pulled from local SQLite,
|
||||||
|
// which turned out to be stale/near-empty — the real working data was in a local Postgres
|
||||||
|
// instance instead. This wipes the TARGET (cloud) hq database's public schema and reloads it
|
||||||
|
// from the real SOURCE (local Postgres). Both engines are Postgres here, so every column type
|
||||||
|
// matches exactly — no conversion needed.
|
||||||
|
// SOURCE_DATABASE_URL and TARGET_DATABASE_URL must both be set explicitly, no defaults, so
|
||||||
|
// this can never accidentally point at (or wipe) the wrong database.
|
||||||
|
//
|
||||||
|
// Run from repo root:
|
||||||
|
// SOURCE_DATABASE_URL=postgres://hq:<local-pw>@127.0.0.1:5432/hq ^
|
||||||
|
// TARGET_DATABASE_URL=postgres://postgres:<cloud-pw>@13.232.53.115:5432/hq ^
|
||||||
|
// npx tsx apps/hq/scripts/migrate-pg-to-pg.ts
|
||||||
|
import { Pool } from 'pg'
|
||||||
|
import { openPgDb, type PgDb } from '../src/db-pg'
|
||||||
|
|
||||||
|
const SKIP_TABLES = new Set(['stg_client', 'stg_invoice', 'schema_migrations'])
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const sourceUrl = process.env['SOURCE_DATABASE_URL']
|
||||||
|
const targetUrl = process.env['TARGET_DATABASE_URL']
|
||||||
|
if (sourceUrl === undefined || sourceUrl === '') throw new Error('Set SOURCE_DATABASE_URL (local Postgres).')
|
||||||
|
if (targetUrl === undefined || targetUrl === '') throw new Error('Set TARGET_DATABASE_URL (cloud Postgres).')
|
||||||
|
if (sourceUrl === targetUrl) throw new Error('SOURCE and TARGET are identical — refusing.')
|
||||||
|
|
||||||
|
// Wipe the target's public schema completely, then let the app's own migrations rebuild
|
||||||
|
// it clean — guarantees the target schema matches this codebase exactly, not a stale copy.
|
||||||
|
const wipePool = new Pool({ connectionString: targetUrl, max: 1 })
|
||||||
|
await wipePool.query('DROP SCHEMA public CASCADE')
|
||||||
|
await wipePool.query('CREATE SCHEMA public')
|
||||||
|
await wipePool.end()
|
||||||
|
console.log('Target schema wiped.')
|
||||||
|
|
||||||
|
const target = (await openPgDb(targetUrl)) as PgDb // recreates schema via migrations, empty
|
||||||
|
const source = (await openPgDb(sourceUrl)) as PgDb // idempotent no-op if already migrated
|
||||||
|
|
||||||
|
const tables = await source.all<{ tablename: string }>(`SELECT tablename FROM pg_tables WHERE schemaname='public'`)
|
||||||
|
|
||||||
|
const client = await target.pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
// Load in discovery order and ignore FK/trigger checks — safe: one transaction into a
|
||||||
|
// target we just wiped and confirmed empty.
|
||||||
|
await client.query('SET LOCAL session_replication_role = replica')
|
||||||
|
for (const { tablename: name } of tables) {
|
||||||
|
if (SKIP_TABLES.has(name)) {
|
||||||
|
console.log(`skip ${name}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const rows = await source.all<Record<string, unknown>>(`SELECT * FROM ${name}`)
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log(`${name}: 0 rows`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const cols = Object.keys(rows[0]!)
|
||||||
|
const placeholders = cols.map((_, i) => `$${i + 1}`).join(',')
|
||||||
|
const insertSql = `INSERT INTO ${name} (${cols.join(',')}) VALUES (${placeholders})`
|
||||||
|
for (const row of rows) {
|
||||||
|
await client.query(insertSql, cols.map((c) => row[c]))
|
||||||
|
}
|
||||||
|
console.log(`${name}: ${rows.length} row(s) migrated`)
|
||||||
|
}
|
||||||
|
await client.query('COMMIT')
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK')
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
console.log('Migration complete.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e: unknown) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
// Go-live data carry-over: copies every row from the local SQLite dev database into the
|
||||||
|
// target (cloud) Postgres database, preserving values and ids as-is — this is a one-time
|
||||||
|
// full copy, not the "born in Postgres" empty-start path in docs/DEPLOY-HQ.md.
|
||||||
|
// Column types are a straight 1:1 map on this schema (money is bigint paise, flags stay
|
||||||
|
// integer, JSON payloads stay text — see migrations-pg.ts header), so no value conversion
|
||||||
|
// is needed; every column is copied through unchanged.
|
||||||
|
// Refuses to run if the target already has staff_user rows, so a re-run (or pointing at
|
||||||
|
// the wrong DATABASE_URL) can't silently duplicate or clobber data.
|
||||||
|
// stg_client/stg_invoice are APEX-import scratch tables, not real data — skipped.
|
||||||
|
//
|
||||||
|
// Run from repo root, source = local ./data/hq.db (or HQ_DATA_DIR), target = DATABASE_URL:
|
||||||
|
// DATABASE_URL=postgres://postgres:<pw>@<host>:5432/hq npx tsx apps/hq/scripts/migrate-sqlite-to-pg.ts
|
||||||
|
import { openDb, type SqliteDb } from '../src/db'
|
||||||
|
import { openPgDb, type PgDb } from '../src/db-pg'
|
||||||
|
|
||||||
|
const SKIP_TABLES = new Set(['stg_client', 'stg_invoice', 'schema_migrations'])
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const pgUrl = process.env['DATABASE_URL']
|
||||||
|
if (pgUrl === undefined || pgUrl === '') {
|
||||||
|
throw new Error('Set DATABASE_URL to the target (cloud) Postgres connection string.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = openDb(process.env['HQ_DATA_DIR']) as SqliteDb
|
||||||
|
const pg = (await openPgDb(pgUrl)) as PgDb // schema is created by migrations here; seedIfEmpty is NOT called
|
||||||
|
|
||||||
|
const existingStaff = (await pg.get<{ n: number }>(`SELECT COUNT(*) AS n FROM staff_user`))!.n
|
||||||
|
if (existingStaff > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Target already has ${existingStaff} staff_user row(s) — refusing to import over existing data. ` +
|
||||||
|
`Wrong DATABASE_URL, or this has already been migrated.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tables = sqlite.raw
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
|
||||||
|
.all() as { name: string }[]
|
||||||
|
|
||||||
|
const client = await pg.pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
// Load in table-discovery order and ignore FK/trigger checks — safe here because the
|
||||||
|
// whole load is one transaction into a database we just confirmed is empty.
|
||||||
|
await client.query('SET LOCAL session_replication_role = replica')
|
||||||
|
for (const { name } of tables) {
|
||||||
|
if (SKIP_TABLES.has(name)) {
|
||||||
|
console.log(`skip ${name}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const rows = sqlite.raw.prepare(`SELECT * FROM ${name}`).all() as Record<string, unknown>[]
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log(`${name}: 0 rows`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const cols = Object.keys(rows[0]!)
|
||||||
|
const placeholders = cols.map((_, i) => `$${i + 1}`).join(',')
|
||||||
|
const insertSql = `INSERT INTO ${name} (${cols.join(',')}) VALUES (${placeholders})`
|
||||||
|
for (const row of rows) {
|
||||||
|
await client.query(insertSql, cols.map((c) => row[c]))
|
||||||
|
}
|
||||||
|
console.log(`${name}: ${rows.length} row(s) migrated`)
|
||||||
|
}
|
||||||
|
await client.query('COMMIT')
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK')
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
console.log('Migration complete.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e: unknown) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,149 @@
|
|||||||
|
// 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
// 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,30 +1,58 @@
|
|||||||
// apps/hq/test/milestones-templates.test.ts — per-module onboarding template resolution.
|
// apps/hq/test/milestones-templates.test.ts — composed (front + per-module tail) onboarding
|
||||||
|
// template resolution.
|
||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { openDb, type DB } from '../src/db'
|
import { openDb, type DB } from '../src/db'
|
||||||
import { milestoneTemplate } from '../src/repos-milestones'
|
import { milestoneTemplate, FRONT_FALLBACK, TAIL_FALLBACK } from '../src/repos-milestones'
|
||||||
|
|
||||||
describe('per-module milestone templates', () => {
|
describe('composed milestone templates (front + tail)', () => {
|
||||||
let db: DB
|
let db: DB
|
||||||
beforeEach(() => { db = openDb(':memory:') })
|
beforeEach(() => { db = openDb(':memory:') })
|
||||||
|
|
||||||
it('falls back to the global template when no module-specific one exists', async () => {
|
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 () => {
|
||||||
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
|
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
|
||||||
JSON.stringify([{ key: 'a', label: 'A' }]))
|
JSON.stringify([{ key: 'a', label: 'A' }]))
|
||||||
expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'a', label: 'A' }])
|
expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, { key: 'a', label: 'A' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prefers the module-specific template over the global one', async () => {
|
it('tail: prefers the module-specific template over the global one', async () => {
|
||||||
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
|
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
|
||||||
JSON.stringify([{ key: 'a', label: 'A' }]))
|
JSON.stringify([{ key: 'a', label: 'A' }]))
|
||||||
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`,
|
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`,
|
||||||
JSON.stringify([{ key: 'doc', label: 'Document collection' }]))
|
JSON.stringify([{ key: 'doc', label: 'Document collection' }]))
|
||||||
expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'doc', label: 'Document collection' }])
|
expect(await milestoneTemplate(db, 'SMS'))
|
||||||
// A different module still gets the global one.
|
.toEqual([...FRONT_FALLBACK, { key: 'doc', label: 'Document collection' }])
|
||||||
expect(await milestoneTemplate(db, 'RTGS')).toEqual([{ key: 'a', label: 'A' }])
|
// 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' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to the code default when nothing is seeded', async () => {
|
it('a malformed or empty per-module tail setting is treated as absent', async () => {
|
||||||
const t = await milestoneTemplate(db, 'SMS')
|
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`, 'not json')
|
||||||
expect(t.map((e) => e.key)).toContain('go_live')
|
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])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -0,0 +1,224 @@
|
|||||||
|
// 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
// 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
// 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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…
Reference in New Issue