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.
295 lines
15 KiB
TypeScript
295 lines
15 KiB
TypeScript
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','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
|
|
);
|
|
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
|
|
);
|
|
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,
|
|
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
|
|
);
|
|
`
|
|
|
|
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)
|
|
migrate(db)
|
|
return db
|
|
}
|
|
|
|
/** Additive, idempotent column adds for DBs created before a schema change. */
|
|
function migrate(db: DB): 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`)
|
|
}
|
|
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`)
|
|
}
|
|
}
|
|
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: DB, 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: DB): 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: DB): 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'],
|
|
)
|
|
}
|