From 9c59430c3150a21d761d975144eb2e826036017c Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sat, 18 Jul 2026 00:38:39 +0530 Subject: [PATCH] perf/fix(d24): DB indexes (both engines) + case-insensitive client search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red-team backend-quality highs/mediums: - Added 30 indexes on the foreign-key / status / date / renewal columns the lists, reports, joins and scans read — zero existed, so every query full-scanned (tolerable on dev SQLite, a real problem on production Postgres at 300 clients / thousands of tickets & documents). Same DDL on both engines: INDEX_DDL in the SQLite SCHEMA + pg migration 007 (CREATE INDEX IF NOT EXISTS, idempotent). - Client search + district/sector filters now LOWER(...) LIKE LOWER(?): bare LIKE is case-insensitive on SQLite but case-SENSITIVE on Postgres, so search silently broke on the prod engine. Test locks case-insensitivity. Verified: pg migrations 006+007 apply cleanly on local Postgres; suite green. Co-Authored-By: Claude Fable 5 --- apps/hq/src/db.ts | 43 ++++++++++++++++++++++++++++- apps/hq/src/migrations-pg.ts | 37 +++++++++++++++++++++++++ apps/hq/src/repos-clients.ts | 9 ++++-- apps/hq/test/client-support.test.ts | 9 ++++++ 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index 63fcf4d..e506212 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -98,6 +98,47 @@ export class SqliteDb implements DB { async close(): Promise { 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. + */ +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 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 ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, @@ -313,7 +354,7 @@ CREATE TABLE IF NOT EXISTS aws_usage ( updated_at TEXT NOT NULL, UNIQUE (client_id, month) -- one row per client per month; the pull upserts on this key ); -` +${INDEX_DDL}` /** 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. */ diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index 8c9b977..1e825b2 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -242,6 +242,43 @@ CREATE TABLE IF NOT EXISTS project_milestone ( sql: ` ALTER TABLE staff_user ADD COLUMN IF NOT EXISTS failed_count integer NOT NULL DEFAULT 0; ALTER TABLE staff_user ADD COLUMN IF NOT EXISTS locked_until text; +`, + }, + { + // D24 (red-team): indexes for the queries that run over the real data. Same DDL as the + // SQLite SCHEMA's INDEX_DDL (CREATE INDEX IF NOT EXISTS is valid on both engines). + id: '007-indexes', + sql: ` +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 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); `, }, ] diff --git a/apps/hq/src/repos-clients.ts b/apps/hq/src/repos-clients.ts index a6d29f8..bb22b08 100644 --- a/apps/hq/src/repos-clients.ts +++ b/apps/hq/src/repos-clients.ts @@ -54,18 +54,21 @@ export async function listClients( ): Promise { let where = ' WHERE 1=1' const args: unknown[] = [] + // D24 (red-team): LOWER(...) LIKE LOWER(?) — bare LIKE is case-INsensitive on SQLite + // but case-SENSITIVE on Postgres, so search silently broke on the production engine. + // LOWER on both sides matches on both engines. if (q !== undefined && q !== '') { const like = `%${q}%` - where += ` AND (name LIKE ? OR code LIKE ? OR gstin LIKE ?)` + where += ` AND (LOWER(name) LIKE LOWER(?) OR LOWER(code) LIKE LOWER(?) OR LOWER(gstin) LIKE LOWER(?))` args.push(like, like, like) } // D18 WS-F: the old book's two working filters — LIKE, so a partial "Ern" // already narrows to Ernakulam while the user types. if (filters.district !== undefined && filters.district !== '') { - where += ` AND district LIKE ?`; args.push(`%${filters.district}%`) + where += ` AND LOWER(district) LIKE LOWER(?)`; args.push(`%${filters.district}%`) } if (filters.sector !== undefined && filters.sector !== '') { - where += ` AND sector LIKE ?`; args.push(`%${filters.sector}%`) + where += ` AND LOWER(sector) LIKE LOWER(?)`; args.push(`%${filters.sector}%`) } const rows = await db.all(`SELECT * FROM client${where} ORDER BY name`, ...args) return rows.map(toClient) diff --git a/apps/hq/test/client-support.test.ts b/apps/hq/test/client-support.test.ts index 88caf72..d2d4165 100644 --- a/apps/hq/test/client-support.test.ts +++ b/apps/hq/test/client-support.test.ts @@ -35,6 +35,15 @@ describe('client support data (repo)', () => { expect(await listClients(db)).toHaveLength(3) // no filter = everything, unchanged }) + it('search + filters are case-insensitive (D24 — works on Postgres too)', async () => { + const db = openDb(':memory:'); await seedIfEmpty(db) + await createClient(db, 'u1', { name: 'Karapuzha Service Bank', stateCode: '32', district: 'Ernakulam' }) + // Lower-cased query matches the mixed-case name; wrong-case district still matches. + expect(await listClients(db, 'karapuzha')).toHaveLength(1) + expect(await listClients(db, 'BANK')).toHaveLength(1) + expect(await listClients(db, undefined, { district: 'ERNAKULAM' })).toHaveLength(1) + }) + it('DB password: encrypted at rest, never in payloads, reveal decrypts + audits', async () => { const db = openDb(':memory:'); await seedIfEmpty(db) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })