feat(hq): sqlite schema + audit log (HQ-1 task 2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent d17726941f
commit 89e468eeda

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

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

@ -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' })
})
})
Loading…
Cancel
Save