You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/db.ts

589 lines
31 KiB
TypeScript

import { AsyncLocalStorage } from 'node:async_hooks'
import Database from 'better-sqlite3'
import fs from 'node:fs'
import path from 'node:path'
/**
* HQ console DB handle (D19): ONE async interface, two engines — better-sqlite3
* for dev/tests (this file), Postgres for production (`db-pg.ts`, selected by
* `DATABASE_URL`). Repos speak portable SQL with `?` placeholders; each engine
* adapter owns its dialect differences. Every data-access call is awaited.
*/
export interface DB {
/** All rows. */
all<T = unknown>(sql: string, ...args: unknown[]): Promise<T[]>
/** First row, or undefined. */
get<T = unknown>(sql: string, ...args: unknown[]): Promise<T | undefined>
/** INSERT/UPDATE/DELETE — resolves with the affected-row count. */
run(sql: string, ...args: unknown[]): Promise<{ changes: number }>
/** Multi-statement DDL, no params. */
exec(sql: string): Promise<void>
/** Atomic unit; nested calls become savepoints. (No trailing `()` — unlike better-sqlite3.) */
transaction<T>(fn: () => Promise<T> | T): Promise<T>
readonly engine: 'sqlite' | 'postgres'
close(): Promise<void>
}
/** The underlying better-sqlite3 handle — migrations and schema tests only. */
export type SqliteRaw = Database.Database
/** better-sqlite3 behind the async interface — the dev/test engine. */
export class SqliteDb implements DB {
readonly engine = 'sqlite' as const
private stmts = new Map<string, Database.Statement>()
// D24 (red-team): a single SQLite connection cannot host two overlapping
// BEGIN..COMMIT blocks. `mutex` is a promise chain that serializes TOP-LEVEL
// transactions so only one is ever open; `als` tracks re-entrancy on a call
// chain so a genuinely nested transaction() becomes a SAVEPOINT (and does NOT
// wait on the mutex — that would deadlock, since it runs inside the held lock).
private mutex: Promise<void> = Promise.resolve()
private als = new AsyncLocalStorage<{ depth: number }>()
constructor(readonly raw: SqliteRaw) {}
private stmt(sql: string): Database.Statement {
let s = this.stmts.get(sql)
if (s === undefined) { s = this.raw.prepare(sql); this.stmts.set(sql, s) }
return s
}
async all<T>(sql: string, ...args: unknown[]): Promise<T[]> {
return this.stmt(sql).all(...args) as T[]
}
async get<T>(sql: string, ...args: unknown[]): Promise<T | undefined> {
return this.stmt(sql).get(...args) as T | undefined
}
async run(sql: string, ...args: unknown[]): Promise<{ changes: number }> {
return { changes: this.stmt(sql).run(...args).changes }
}
async exec(sql: string): Promise<void> { this.raw.exec(sql) }
async transaction<T>(fn: () => Promise<T> | T): Promise<T> {
const store = this.als.getStore()
if (store !== undefined) {
// Nested on the same call chain → savepoint (the outer BEGIN already holds
// the mutex, so we must not queue on it).
const sp = `sp_d${++store.depth}`
this.raw.exec(`SAVEPOINT ${sp}`)
try {
const out = await fn()
this.raw.exec(`RELEASE ${sp}`)
return out
} catch (err) {
this.raw.exec(`ROLLBACK TO ${sp}; RELEASE ${sp}`)
throw err
} finally {
store.depth--
}
}
// Top-level: take the mutex so no other BEGIN overlaps this one. Chain onto the
// previous transaction's tail (ignoring its rejection) and expose our own tail.
let release!: () => void
const prior = this.mutex
this.mutex = new Promise<void>((r) => { release = r })
await prior.catch(() => undefined)
try {
return await this.als.run({ depth: 0 }, async () => {
this.raw.exec('BEGIN')
try {
const out = await fn()
this.raw.exec('COMMIT')
return out
} catch (err) {
this.raw.exec('ROLLBACK')
throw err
}
})
} finally {
release()
}
}
async close(): Promise<void> { this.raw.close() }
}
/**
* D24 (red-team): indexes for the queries that run over the real data (300 clients,
* thousands of documents/tickets/interactions). Without these every list, report and
* join full-scans — tolerable on dev SQLite, a real problem on production Postgres.
* `CREATE INDEX IF NOT EXISTS` is valid on both engines, so this same DDL is applied by
* the SQLite SCHEMA below and by the Postgres migration set. Columns chosen: foreign keys
* used in joins/filters, and the status/date/renewal columns the scans and boards read.
*/
/**
* D37: derive the society category from the client name, matching the Kerala co-op directory
* taxonomy. Order matters — most specific first (a "VANITHA … SOCIETY" is Vanitha, not the
* generic Society). Portable SQL (UPPER + LIKE) so SQLite and Postgres backfill identically.
*/
export const CATEGORY_BACKFILL_SQL = `
UPDATE client SET category = CASE
WHEN client_type = 'Store' THEN 'Store'
WHEN UPPER(name) LIKE '%VANITHA%' THEN 'Vanitha'
WHEN UPPER(name) LIKE '%HOUSING%' THEN 'Housing'
WHEN UPPER(name) LIKE '%URBAN%' THEN 'Urban Bank'
WHEN UPPER(name) LIKE '%EMPLOYEE%' OR UPPER(name) LIKE '%TEACHER%' OR UPPER(name) LIKE '%STAFF%' THEN 'Employees'
WHEN UPPER(name) LIKE '%MARKETING%' THEN 'Marketing'
WHEN UPPER(name) LIKE '%AGRICULTUR%' OR UPPER(name) LIKE '%RURAL DEVELOPMENT%' OR UPPER(name) LIKE '%RUBBER%' THEN 'Agricultural'
WHEN UPPER(name) LIKE '%SERVICE CO-OPERATIVE%' OR UPPER(name) LIKE '%SERVICE BANK%' OR UPPER(name) LIKE '%SCB%' THEN 'Service Bank'
WHEN UPPER(name) LIKE '%BANK%' THEN 'Bank (other)'
WHEN UPPER(name) LIKE '%SOCIET%' OR UPPER(name) LIKE '%CO-OPERATIVE%' OR UPPER(name) LIKE '%AICOS%' THEN 'Society (other)'
ELSE 'Other'
END`
export const INDEX_DDL = `
CREATE INDEX IF NOT EXISTS ix_document_client ON document (client_id);
CREATE INDEX IF NOT EXISTS ix_document_type_status ON document (doc_type, status);
CREATE INDEX IF NOT EXISTS ix_document_ref ON document (ref_doc_id);
CREATE INDEX IF NOT EXISTS ix_document_created_by ON document (created_by);
CREATE INDEX IF NOT EXISTS ix_document_event_doc ON document_event (document_id);
CREATE INDEX IF NOT EXISTS ix_document_share_doc ON document_share (document_id);
CREATE INDEX IF NOT EXISTS ix_payment_client ON payment (client_id);
CREATE INDEX IF NOT EXISTS ix_alloc_doc ON payment_allocation (document_id);
CREATE INDEX IF NOT EXISTS ix_alloc_payment ON payment_allocation (payment_id);
CREATE INDEX IF NOT EXISTS ix_client_module_client ON client_module (client_id);
CREATE INDEX IF NOT EXISTS ix_client_module_module ON client_module (module_id, active);
CREATE INDEX IF NOT EXISTS ix_client_module_renewal ON client_module (next_renewal);
CREATE INDEX IF NOT EXISTS ix_client_owner ON client (owner_id);
CREATE INDEX IF NOT EXISTS ix_client_branch_client ON client_branch (client_id);
CREATE INDEX IF NOT EXISTS ix_ticket_client ON ticket (client_id);
CREATE INDEX IF NOT EXISTS ix_ticket_status ON ticket (status);
CREATE INDEX IF NOT EXISTS ix_ticket_assigned ON ticket (assigned_to);
CREATE INDEX IF NOT EXISTS ix_interaction_client ON interaction (client_id);
CREATE INDEX IF NOT EXISTS ix_interaction_followup ON interaction (follow_up_on);
CREATE INDEX IF NOT EXISTS ix_reminder_status ON reminder (status);
CREATE INDEX IF NOT EXISTS ix_reminder_client ON reminder (client_id);
CREATE INDEX IF NOT EXISTS ix_reminder_doc ON reminder (doc_id);
CREATE INDEX IF NOT EXISTS ix_price_book_module ON module_price_book (module_id);
CREATE INDEX IF NOT EXISTS ix_rate_band_module ON usage_rate_band (module_id, effective_from);
CREATE INDEX IF NOT EXISTS ix_recurring_active_run ON recurring_plan (active, next_run);
CREATE INDEX IF NOT EXISTS ix_amc_client ON amc_contract (client_id);
CREATE INDEX IF NOT EXISTS ix_aws_client ON aws_usage (client_id);
CREATE INDEX IF NOT EXISTS ix_session_staff ON session (staff_id);
CREATE UNIQUE INDEX IF NOT EXISTS ix_staff_username ON staff_user (username);
CREATE INDEX IF NOT EXISTS ix_audit_entity ON audit_log (entity, entity_id);
CREATE INDEX IF NOT EXISTS ix_milestone_cm ON project_milestone (client_module_id);
`
const SCHEMA = `
CREATE TABLE IF NOT EXISTS staff_user (
-- D25: LOGIN is by username (unique). email is now just a contact field on the user,
-- not the login identifier. New DBs get username on insert; older DBs backfill
-- username = email in migrate() so existing logins keep working unchanged.
id TEXT PRIMARY KEY, username TEXT, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner','manager','staff')),
phone TEXT, title TEXT, -- D18 WS-E: contact + designation shown on Employees/profile; nothing routes on them
pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1,
-- D24 (red-team): brute-force lockout. failed_count counts consecutive failed logins;
-- locked_until is an ISO timestamp the account is barred until (NULL = not locked).
-- Both reset to 0/NULL on a successful login.
failed_count INTEGER NOT NULL DEFAULT 0, locked_until TEXT,
-- D34: set for invited employees given a default password; cleared on first self-change.
must_change_password INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS session (
token TEXT PRIMARY KEY, staff_id TEXT NOT NULL, expires_at TEXT NOT NULL
);
-- D36: login attempt log — who/when/from-where, success and failure. Security telemetry
-- (not the domain audit): every /auth/login attempt records IP + user-agent + outcome.
CREATE TABLE IF NOT EXISTS login_event (
id TEXT PRIMARY KEY, at_wall TEXT NOT NULL,
staff_id TEXT, -- NULL when the username didn't match a user
username_tried TEXT NOT NULL,
outcome TEXT NOT NULL, -- 'success' | 'failed' | 'locked'
ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT ''
);
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')),
owner_id TEXT, -- account/enquiry owner → staff_user.id; NULL = unassigned (routing, D-EMP A4)
-- D18 WS-F: support-access data from the APEX book. db_password_enc is AES-256-GCM
-- (HQ_SECRET_KEY) and never appears in list/get payloads — reveal-only, audited.
anydesk TEXT, os TEXT, district TEXT, sector TEXT, db_password_enc TEXT,
-- D35: 'PACS' (co-op society) vs 'Store' (retail); backfilled by name, editable per client.
client_type TEXT NOT NULL DEFAULT 'PACS',
-- D37: society category (Service Bank / Vanitha / Housing / Employees / …); derived from name.
category TEXT NOT NULL DEFAULT 'Other',
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,
quote_content TEXT NOT NULL DEFAULT '[]', -- JSON string[]: "what's included" lines on quotes
-- D21: the module DECLARES its own operational fields (config over code). JSON
-- FieldDef[] { key, label, type, options?, required?, help?, secret?, sort? }.
-- The per-client values live on client_module (field_values + secrets_enc),
-- validated + rendered against this — a new module/field needs no code change.
field_spec TEXT NOT NULL DEFAULT '[]',
-- D25: fixed display order across the app (config over code — owner-editable).
-- Lower sorts first; ties broken by code. Default 100 so unseeded modules trail.
sort_order INTEGER NOT NULL DEFAULT 100
);
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
);
-- D23 quantity-tiered pricing (SMS packs and any future usage-billed module). A dated
-- "rate card" = all bands sharing (module_id, effective_from). A purchase of N units
-- resolves to the band with the highest min_qty <= N; N below the lowest min_qty is
-- under the minimum order. Rates are integer paise per unit; a new card is a new dated
-- set of rows, never an edit (config over code — old invoices keep their old rate).
CREATE TABLE IF NOT EXISTS usage_rate_band (
id TEXT PRIMARY KEY, module_id TEXT NOT NULL, effective_from TEXT NOT NULL,
min_qty INTEGER NOT NULL, rate_paise INTEGER 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,
-- D20 per-service operational data (APEX parity). password_enc is AES-256-GCM
-- (HQ_SECRET_KEY), reveal-only + audited, never in list payloads — like the
-- client DB password. details is JSON [{label, value}] free-form key/values.
provider TEXT, username TEXT, password_enc TEXT,
details TEXT NOT NULL DEFAULT '[]', remark TEXT,
-- D21 module-defined fields: field_values is a JSON map { fieldKey: value } for the
-- module's non-secret declared fields; secrets_enc is ONE AES-256-GCM blob over a JSON
-- map { fieldKey: plaintext } for its secret fields (N secrets, one encrypt; reveal
-- audited, never in list payloads). Keys are validated against the module's field_spec.
field_values TEXT NOT NULL DEFAULT '{}', secrets_enc TEXT
);
CREATE TABLE IF NOT EXISTS client_branch (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, name TEXT NOT NULL,
code TEXT, active INTEGER NOT NULL DEFAULT 1
);
-- D22 onboarding tracker: each client_module (a "project") gets a checklist of milestones
-- seeded from the dated project.milestone_template setting on assignment. done_on stamps
-- when a milestone is ticked. Reportable across all projects (who is pending go-live).
CREATE TABLE IF NOT EXISTS project_milestone (
id TEXT PRIMARY KEY, client_module_id TEXT NOT NULL,
key TEXT NOT NULL, label TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0, done_on TEXT, sort INTEGER NOT NULL DEFAULT 0,
UNIQUE (client_module_id, key)
);
CREATE TABLE IF NOT EXISTS ticket (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, branch_id TEXT,
module_code TEXT, -- 'SMS' | 'RTGS' | ... (free text; module codes by convention)
kind TEXT NOT NULL DEFAULT '', -- MODIFICATION / SOFTWARE ADDONS / ... (config over code: free text + history suggestions)
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','in_progress','waiting','closed','dropped')),
assigned_to TEXT, -- staff_user.id; NULL = unassigned (team-visible workbench)
online_offline TEXT, -- 'online' | 'offline' | NULL (APEX parity)
opened_on TEXT NOT NULL, closed_on TEXT,
created_by TEXT NOT NULL, created_at TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'hq' -- 'apex' rows are the imported history
);
-- D28: SMS gateway balance-check telemetry (one row per SMS subscription). Operational
-- sync log, NOT audited domain data — the authoritative balance lives in client_module
-- field_values.sms_balance (that change IS audited). This just records the last poll.
CREATE TABLE IF NOT EXISTS sms_balance_check (
client_module_id TEXT PRIMARY KEY,
status TEXT NOT NULL, -- 'ok' | 'error'
balance INTEGER, -- parsed count on success, NULL on error
message TEXT, -- gateway status/error text (never a credential)
checked_at TEXT NOT NULL
);
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,
due_date TEXT, -- INVOICE only: stamped at issue from billing.payment_terms_days when absent (D18)
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 document_share (
id TEXT PRIMARY KEY, document_id TEXT NOT NULL, token TEXT NOT NULL UNIQUE,
created_by TEXT NOT NULL, created_at TEXT NOT NULL,
expires_at TEXT, -- NULL = never expires
revoked INTEGER NOT NULL DEFAULT 0
);
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,
anydesk TEXT, os TEXT, district TEXT, sector 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 '[]'
);
CREATE TABLE IF NOT EXISTS recurring_plan (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL,
client_module_id TEXT, -- source of the invoice line's module; required for generation in HQ-2
cadence TEXT NOT NULL CHECK (cadence IN ('monthly','yearly')),
amount_paise INTEGER, -- NULL = price from module_price_book at generation time
next_run TEXT NOT NULL,
policy TEXT NOT NULL DEFAULT 'manual' CHECK (policy IN ('auto','manual')),
active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS amc_contract (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, coverage TEXT NOT NULL DEFAULT '',
period_from TEXT NOT NULL, period_to TEXT NOT NULL, amount_paise INTEGER NOT NULL,
renewal_reminder_days INTEGER NOT NULL DEFAULT 30,
legacy_paid INTEGER, -- manual flag for imported contracts only; else paid derives from invoice_doc_id
invoice_doc_id TEXT, active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS interaction_type (
code TEXT PRIMARY KEY, label TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS interaction (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, type_code TEXT NOT NULL,
on_date TEXT NOT NULL, staff_id TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '',
outcome TEXT CHECK (outcome IN ('positive','neutral','negative')),
follow_up_on TEXT, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS reminder (
id TEXT PRIMARY KEY,
rule_kind TEXT NOT NULL CHECK (rule_kind IN
('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced','quote_followup')),
subject_id TEXT NOT NULL, due_period TEXT NOT NULL, client_id TEXT NOT NULL,
doc_id TEXT, -- the document this reminder concerns (overdue/recurring/amc invoice); NULL otherwise
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')),
policy_applied TEXT NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')),
error TEXT, created_at TEXT NOT NULL, sent_at TEXT,
UNIQUE (rule_kind, subject_id, due_period) -- the idempotency key: at-most-once per due-period
);
CREATE TABLE IF NOT EXISTS reminder_schedule (
id TEXT PRIMARY KEY, rule_kind TEXT NOT NULL, -- 'quote_followup' | 'invoice_overdue' (validated in repo)
effective_from TEXT NOT NULL, effective_to TEXT, -- dated config window (rule 3): active when from<=today AND (to IS NULL OR to>today)
day_offsets TEXT NOT NULL, -- CSV day milestones since anchor, e.g. '3,7,14'
subject TEXT, body TEXT -- dated message text (quote_followup); NULL = code-constant fallback
);
CREATE TABLE IF NOT EXISTS aws_usage (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL,
month TEXT NOT NULL, -- 'YYYY-MM'
storage_gb REAL NOT NULL DEFAULT 0, -- GB stored, month-end snapshot (manual or CloudWatch later)
transfer_gb REAL NOT NULL DEFAULT 0, -- GB transferred over the month
cost_paise INTEGER NOT NULL DEFAULT 0, -- Cost Explorer UnblendedCost for the 'client' tag, in paise (INR)
source TEXT NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','manual')),
updated_at TEXT NOT NULL,
UNIQUE (client_id, month) -- one row per client per month; the pull upserts on this key
);
`
/** Open the SQLite engine (dev/tests). Deliberately sync — schema + migrate run
* on the raw handle before it is wrapped, so test fixtures stay one-liners. */
export function openDb(dataDir?: string): DB {
let raw: SqliteRaw
if (dataDir === ':memory:') {
raw = new Database(':memory:')
} else {
const dir = dataDir ?? path.resolve(process.cwd(), 'data')
fs.mkdirSync(dir, { recursive: true })
raw = new Database(path.join(dir, 'hq.db'))
raw.pragma('journal_mode = WAL')
}
raw.exec(SCHEMA)
migrate(raw)
// Indexes run AFTER migrate() so every column they reference exists on an older DB
// that migrate() has just patched (e.g. client.owner_id, the D24 lockout columns).
raw.exec(INDEX_DDL)
return new SqliteDb(raw)
}
/** Additive, idempotent column adds for DBs created before a schema change.
* SQLite-only path on the raw handle; Postgres migrates via numbered SQL files. */
function migrate(db: SqliteRaw): void {
const moduleCols = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[]
if (!moduleCols.some((c) => c.name === 'quote_content')) {
db.exec(`ALTER TABLE module ADD COLUMN quote_content TEXT NOT NULL DEFAULT '[]'`)
}
const emailCols = db.prepare(`PRAGMA table_info(email_log)`).all() as { name: string }[]
if (!emailCols.some((c) => c.name === 'bounced')) {
db.exec(`ALTER TABLE email_log ADD COLUMN bounced INTEGER NOT NULL DEFAULT 0`)
}
const docCols = db.prepare(`PRAGMA table_info(document)`).all() as { name: string }[]
if (!docCols.some((c) => c.name === 'due_date')) {
db.exec(`ALTER TABLE document ADD COLUMN due_date TEXT`)
}
const staffCols = db.prepare(`PRAGMA table_info(staff_user)`).all() as { name: string }[]
if (!staffCols.some((c) => c.name === 'username')) {
db.exec(`ALTER TABLE staff_user ADD COLUMN username TEXT`)
db.exec(`UPDATE staff_user SET username=email WHERE username IS NULL`) // login keeps working
}
if (!staffCols.some((c) => c.name === 'phone')) {
db.exec(`ALTER TABLE staff_user ADD COLUMN phone TEXT`)
}
if (!staffCols.some((c) => c.name === 'title')) {
db.exec(`ALTER TABLE staff_user ADD COLUMN title TEXT`)
}
// D24 (red-team): login lockout columns.
if (!staffCols.some((c) => c.name === 'failed_count')) {
db.exec(`ALTER TABLE staff_user ADD COLUMN failed_count INTEGER NOT NULL DEFAULT 0`)
}
if (!staffCols.some((c) => c.name === 'locked_until')) {
db.exec(`ALTER TABLE staff_user ADD COLUMN locked_until TEXT`)
}
// D34: invited employees start with a default password they must change — flag drives
// the "Invited / pending first login" badge; cleared on the user's first password change.
if (!staffCols.some((c) => c.name === 'must_change_password')) {
db.exec(`ALTER TABLE staff_user ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`)
}
const clientCols = db.prepare(`PRAGMA table_info(client)`).all() as { name: string }[]
if (!clientCols.some((c) => c.name === 'owner_id')) {
db.exec(`ALTER TABLE client ADD COLUMN owner_id TEXT`)
}
// D18 WS-F: support-access fields from the APEX client book.
for (const col of ['anydesk', 'os', 'district', 'sector', 'db_password_enc']) {
if (!clientCols.some((c) => c.name === col)) {
db.exec(`ALTER TABLE client ADD COLUMN ${col} TEXT`)
}
}
// D35: client type — 'PACS' (co-op society) vs 'Store' (retail). Backfilled by name:
// a name ending in STORE/STORES is a Store, everything else a PACS. Editable per client.
if (!clientCols.some((c) => c.name === 'client_type')) {
db.exec(`ALTER TABLE client ADD COLUMN client_type TEXT NOT NULL DEFAULT 'PACS'`)
db.exec(`UPDATE client SET client_type='Store' WHERE UPPER(TRIM(name)) LIKE '%STORE' OR UPPER(TRIM(name)) LIKE '%STORES'`)
}
// D37: society category matching the Kerala co-op directory taxonomy — derived from the
// name (Service Bank / Urban Bank / Vanitha / Housing / Employees / Agricultural / …).
if (!clientCols.some((c) => c.name === 'category')) {
db.exec(`ALTER TABLE client ADD COLUMN category TEXT NOT NULL DEFAULT 'Other'`)
db.exec(CATEGORY_BACKFILL_SQL)
}
// …and the same fields on the import staging table so the cutover carries them.
const stgClientCols = db.prepare(`PRAGMA table_info(stg_client)`).all() as { name: string }[]
for (const col of ['anydesk', 'os', 'district', 'sector']) {
if (!stgClientCols.some((c) => c.name === col)) {
db.exec(`ALTER TABLE stg_client ADD COLUMN ${col} TEXT`)
}
}
// D20 per-service operational data on client_module (APEX parity).
const cmCols = db.prepare(`PRAGMA table_info(client_module)`).all() as { name: string }[]
for (const col of ['provider', 'username', 'password_enc', 'remark', 'secrets_enc']) {
if (!cmCols.some((c) => c.name === col)) {
db.exec(`ALTER TABLE client_module ADD COLUMN ${col} TEXT`)
}
}
if (!cmCols.some((c) => c.name === 'details')) {
db.exec(`ALTER TABLE client_module ADD COLUMN details TEXT NOT NULL DEFAULT '[]'`)
}
// D21 module-defined field schema.
if (!cmCols.some((c) => c.name === 'field_values')) {
db.exec(`ALTER TABLE client_module ADD COLUMN field_values TEXT NOT NULL DEFAULT '{}'`)
}
const modCols2 = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[]
if (!modCols2.some((c) => c.name === 'field_spec')) {
db.exec(`ALTER TABLE module ADD COLUMN field_spec TEXT NOT NULL DEFAULT '[]'`)
}
if (!modCols2.some((c) => c.name === 'sort_order')) {
db.exec(`ALTER TABLE module ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 100`)
}
rebuildStaffUserRoleCheck(db)
rebuildReminderRuleKindCheck(db)
}
/**
* Rebuild a table whose CHECK constraint must change — SQLite cannot ALTER a CHECK in
* place, so the table is recreated and rows copied with an explicit column list.
* Guarded: a no-op unless `guardToken` (a fragment of the OLD constraint) is still
* present in the live DDL, so fresh DBs (born correct from SCHEMA) and re-runs skip it.
* The whole rebuild runs in one transaction (crash-safe).
* SQLite-only migration path — on Postgres (prod, D15) this is a one-line
* ALTER TABLE … DROP/ADD CONSTRAINT in the prod migration set.
*/
export function rebuildTable(
db: SqliteRaw, table: string, newDdl: string, guardToken: string, columns: string[],
): void {
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`)
.get(table) as { sql: string } | undefined
if (!row || !row.sql.includes(guardToken)) return
const cols = columns.join(', ')
db.transaction(() => {
db.exec(newDdl)
db.exec(`INSERT INTO ${table}__new (${cols}) SELECT ${cols} FROM ${table}`)
db.exec(`DROP TABLE ${table}`)
db.exec(`ALTER TABLE ${table}__new RENAME TO ${table}`)
})()
}
/** Widen staff_user.role CHECK to allow 'manager' on DBs created before the employee slice. */
export function rebuildStaffUserRoleCheck(db: SqliteRaw): void {
rebuildTable(
db,
'staff_user',
`CREATE TABLE staff_user__new (
id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner','manager','staff')),
pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1
)`,
`('owner','staff')`,
['id', 'email', 'display_name', 'role', 'pw_salt', 'pw_hash', 'active'],
)
}
/**
* Widen reminder.rule_kind CHECK to allow 'quote_followup' on DBs created before the
* quote-follow-up slice (spec §4). The status/policy_applied CHECKs and the
* UNIQUE (rule_kind, subject_id, due_period) idempotency key are preserved verbatim.
* Guard token: the old list ends "...'email_bounced')" — the new DDL continues
* ",'quote_followup')" instead, so the fragment vanishes once rebuilt (idempotent).
*/
export function rebuildReminderRuleKindCheck(db: SqliteRaw): void {
rebuildTable(
db,
'reminder',
`CREATE TABLE reminder__new (
id TEXT PRIMARY KEY,
rule_kind TEXT NOT NULL CHECK (rule_kind IN
('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced','quote_followup')),
subject_id TEXT NOT NULL, due_period TEXT NOT NULL, client_id TEXT NOT NULL,
doc_id TEXT, -- the document this reminder concerns (overdue/recurring/amc invoice; the quote for quote_followup); NULL otherwise
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')),
policy_applied TEXT NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')),
error TEXT, created_at TEXT NOT NULL, sent_at TEXT,
UNIQUE (rule_kind, subject_id, due_period) -- the idempotency key: at-most-once per due-period
)`,
`'email_bounced')`,
['id', 'rule_kind', 'subject_id', 'due_period', 'client_id', 'doc_id',
'status', 'policy_applied', 'error', 'created_at', 'sent_at'],
)
}