From 89e468eeda58f6d10a4db4fc2a305574f9a80e5e Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 00:46:41 +0530 Subject: [PATCH] feat(hq): sqlite schema + audit log (HQ-1 task 2) Co-Authored-By: Claude Fable 5 --- apps/hq/src/audit.ts | 35 ++++++++++++ apps/hq/src/db.ts | 117 ++++++++++++++++++++++++++++++++++++++++ apps/hq/test/db.test.ts | 23 ++++++++ 3 files changed, 175 insertions(+) create mode 100644 apps/hq/src/audit.ts create mode 100644 apps/hq/src/db.ts create mode 100644 apps/hq/test/db.test.ts diff --git a/apps/hq/src/audit.ts b/apps/hq/src/audit.ts new file mode 100644 index 0000000..ad77a68 --- /dev/null +++ b/apps/hq/src/audit.ts @@ -0,0 +1,35 @@ +import { uuidv7 } from '@sims/domain' +import type { DB } from './db' + +export interface AuditRow { + id: string; at_wall: string; user_id: string; action: string + entity: string; entity_id: string; before_json: string | null; after_json: string | null +} + +// listAudit orders by id DESC (UUIDv7 is time-ordered). Successive writes often land in +// the same millisecond, where UUIDv7's random tail would make the order arbitrary — so we +// bump the id timestamp monotonically per process to keep audit ids strictly ordered. +let lastIdMs = 0 +function nextIdMs(): number { + const now = Date.now() + lastIdMs = now > lastIdMs ? now : lastIdMs + 1 + return lastIdMs +} + +export function writeAudit( + db: DB, userId: string, action: string, entity: string, entityId: string, + before?: unknown, after?: unknown, +): void { + db.prepare( + `INSERT INTO audit_log (id, at_wall, user_id, action, entity, entity_id, before_json, after_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + uuidv7(nextIdMs()), new Date().toISOString(), userId, action, entity, entityId, + before === undefined ? null : JSON.stringify(before), + after === undefined ? null : JSON.stringify(after), + ) +} + +export function listAudit(db: DB, limit = 200): AuditRow[] { + return db.prepare(`SELECT * FROM audit_log ORDER BY id DESC LIMIT ?`).all(limit) as AuditRow[] +} diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts new file mode 100644 index 0000000..9032862 --- /dev/null +++ b/apps/hq/src/db.ts @@ -0,0 +1,117 @@ +import Database from 'better-sqlite3' +import fs from 'node:fs' +import path from 'node:path' + +/** HQ console DB — SQLite behind portable repositories, S3 backup at deploy time. */ +export type DB = Database.Database + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS staff_user ( + id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner','staff')), + pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 +); +CREATE TABLE IF NOT EXISTS session ( + token TEXT PRIMARY KEY, staff_id TEXT NOT NULL, expires_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS client ( + id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + gstin TEXT, state_code TEXT NOT NULL DEFAULT '32', address TEXT NOT NULL DEFAULT '', + contacts TEXT NOT NULL DEFAULT '[]', -- JSON [{name,phone,email,role}] + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('lead','active','dormant','lost')), + notes TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'hq', created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS module ( + id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + sac TEXT NOT NULL DEFAULT '998313', -- IT services default; CA session confirms per module + allowed_kinds TEXT NOT NULL DEFAULT '["one_time","monthly","yearly","usage"]', + multi_subscription INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1 +); +CREATE TABLE IF NOT EXISTS module_price_book ( + id TEXT PRIMARY KEY, module_id TEXT NOT NULL, edition TEXT NOT NULL DEFAULT 'standard', + kind TEXT NOT NULL, price_paise INTEGER NOT NULL, effective_from TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS client_module ( + id TEXT PRIMARY KEY, client_id TEXT NOT NULL, module_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'quoted' CHECK (status IN + ('quoted','ordered','installing','installed','trained','live','expired','cancelled')), + kind TEXT NOT NULL, edition TEXT NOT NULL DEFAULT 'standard', + installed_on TEXT, completed_on TEXT, trained_on TEXT, + next_renewal TEXT, active INTEGER NOT NULL DEFAULT 1 +); +CREATE TABLE IF NOT EXISTS tax_class ( + class_code TEXT NOT NULL, rate_pct_bp INTEGER NOT NULL, + cess_pct_bp INTEGER NOT NULL DEFAULT 0, effective_from TEXT NOT NULL, effective_to TEXT +); +CREATE TABLE IF NOT EXISTS doc_series ( + doc_type TEXT NOT NULL, fy TEXT NOT NULL, prefix TEXT NOT NULL, next_seq INTEGER NOT NULL, + PRIMARY KEY (doc_type, fy) +); +CREATE TABLE IF NOT EXISTS document ( + id TEXT PRIMARY KEY, doc_type TEXT NOT NULL CHECK (doc_type IN + ('QUOTATION','PROFORMA','INVOICE','RECEIPT','CREDIT_NOTE')), + doc_no TEXT, fy TEXT NOT NULL, client_id TEXT NOT NULL, doc_date TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN + ('draft','sent','accepted','invoiced','part_paid','paid','lost','cancelled')), + ref_doc_id TEXT, -- QT→PI→INV chain; CN → the invoice it amends + taxable_paise INTEGER NOT NULL, cgst_paise INTEGER NOT NULL, sgst_paise INTEGER NOT NULL, + igst_paise INTEGER NOT NULL, round_off_paise INTEGER NOT NULL, payable_paise INTEGER NOT NULL, + payload TEXT NOT NULL, -- JSON { lines: BillLine[], totals: BillTotals, terms?: string } + source TEXT NOT NULL DEFAULT 'hq', -- 'apex' rows keep legacy numbers, exempt from series + created_by TEXT NOT NULL, created_at TEXT NOT NULL, + UNIQUE (doc_no) +); +CREATE TABLE IF NOT EXISTS document_event ( + id TEXT PRIMARY KEY, document_id TEXT NOT NULL, at_wall TEXT NOT NULL, + kind TEXT NOT NULL, meta TEXT NOT NULL DEFAULT '{}' +); +CREATE TABLE IF NOT EXISTS payment ( + id TEXT PRIMARY KEY, client_id TEXT NOT NULL, received_on TEXT NOT NULL, + mode TEXT NOT NULL CHECK (mode IN ('bank','upi','cheque','cash','other')), + reference TEXT NOT NULL DEFAULT '', amount_paise INTEGER NOT NULL, + tds_paise INTEGER NOT NULL DEFAULT 0, created_by TEXT NOT NULL, created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS payment_allocation ( + id TEXT PRIMARY KEY, payment_id TEXT NOT NULL, document_id TEXT NOT NULL, + amount_paise INTEGER NOT NULL -- includes this allocation's TDS share +); +CREATE TABLE IF NOT EXISTS email_account ( + id TEXT PRIMARY KEY, address TEXT NOT NULL, refresh_token_enc TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','dead')), updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS email_log ( + id TEXT PRIMARY KEY, document_id TEXT, to_addr TEXT NOT NULL, subject TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('sent','failed')), gmail_message_id TEXT, + error TEXT, at_wall TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS setting ( + key TEXT PRIMARY KEY, value TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, at_wall TEXT NOT NULL, user_id TEXT NOT NULL, action TEXT NOT NULL, + entity TEXT NOT NULL, entity_id TEXT NOT NULL, before_json TEXT, after_json TEXT +); +CREATE TABLE IF NOT EXISTS stg_client ( + row_no INTEGER PRIMARY KEY, code TEXT, name TEXT, gstin TEXT, state_code TEXT, + address TEXT, phone TEXT, email TEXT, status TEXT, problems TEXT NOT NULL DEFAULT '[]' +); +CREATE TABLE IF NOT EXISTS stg_invoice ( + row_no INTEGER PRIMARY KEY, client_code TEXT, doc_no TEXT, doc_date TEXT, + taxable_paise INTEGER, tax_paise INTEGER, total_paise INTEGER, paid INTEGER, + problems TEXT NOT NULL DEFAULT '[]' +); +` + +export function openDb(dataDir?: string): DB { + let db: DB + if (dataDir === ':memory:') { + db = new Database(':memory:') + } else { + const dir = dataDir ?? path.resolve(process.cwd(), 'data') + fs.mkdirSync(dir, { recursive: true }) + db = new Database(path.join(dir, 'hq.db')) + db.pragma('journal_mode = WAL') + } + db.exec(SCHEMA) + return db +} diff --git a/apps/hq/test/db.test.ts b/apps/hq/test/db.test.ts new file mode 100644 index 0000000..e59702a --- /dev/null +++ b/apps/hq/test/db.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { writeAudit, listAudit } from '../src/audit' + +describe('hq db', () => { + it('creates every HQ-1 table', () => { + const db = openDb(':memory:') + const names = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]).map(r => r.name) + for (const t of ['staff_user','session','client','module','module_price_book','client_module', + 'tax_class','doc_series','document','payment','payment_allocation','document_event', + 'email_account','email_log','setting','audit_log','stg_client','stg_invoice']) + expect(names, `missing table ${t}`).toContain(t) + }) + it('audit writes and lists newest-first', () => { + const db = openDb(':memory:') + writeAudit(db, 'u1', 'create', 'client', 'c1', undefined, { name: 'Acme' }) + writeAudit(db, 'u1', 'update', 'client', 'c1', { name: 'Acme' }, { name: 'Acme Ltd' }) + const rows = listAudit(db) + expect(rows).toHaveLength(2) + expect(rows[0]!.action).toBe('update') + expect(JSON.parse(rows[0]!.after_json!)).toEqual({ name: 'Acme Ltd' }) + }) +})