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(sql: string, ...args: unknown[]): Promise /** First row, or undefined. */ get(sql: string, ...args: unknown[]): Promise /** 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 /** Atomic unit; nested calls become savepoints. (No trailing `()` — unlike better-sqlite3.) */ transaction(fn: () => Promise | T): Promise readonly engine: 'sqlite' | 'postgres' close(): Promise } /** 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() // 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 = 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(sql: string, ...args: unknown[]): Promise { return this.stmt(sql).all(...args) as T[] } async get(sql: string, ...args: unknown[]): Promise { 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 { this.raw.exec(sql) } async transaction(fn: () => Promise | T): Promise { 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((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 { this.raw.close() } } 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','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 ); 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')), 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, 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 '[]' ); 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 ); 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) 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 === '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`) } 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`) } } // …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 '[]'`) } 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'], ) }