perf/fix(d24): DB indexes (both engines) + case-insensitive client search

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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 9a9de711b5
commit 9c59430c31

@ -98,6 +98,47 @@ export class SqliteDb implements DB {
async close(): Promise<void> { this.raw.close() } 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.
*/
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 = ` const SCHEMA = `
CREATE TABLE IF NOT EXISTS staff_user ( CREATE TABLE IF NOT EXISTS staff_user (
id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, 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, updated_at TEXT NOT NULL,
UNIQUE (client_id, month) -- one row per client per month; the pull upserts on this key 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 /** 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. */ * on the raw handle before it is wrapped, so test fixtures stay one-liners. */

@ -242,6 +242,43 @@ CREATE TABLE IF NOT EXISTS project_milestone (
sql: ` 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 failed_count integer NOT NULL DEFAULT 0;
ALTER TABLE staff_user ADD COLUMN IF NOT EXISTS locked_until text; 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);
`, `,
}, },
] ]

@ -54,18 +54,21 @@ export async function listClients(
): Promise<Client[]> { ): Promise<Client[]> {
let where = ' WHERE 1=1' let where = ' WHERE 1=1'
const args: unknown[] = [] 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 !== '') { if (q !== undefined && q !== '') {
const like = `%${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) args.push(like, like, like)
} }
// D18 WS-F: the old book's two working filters — LIKE, so a partial "Ern" // D18 WS-F: the old book's two working filters — LIKE, so a partial "Ern"
// already narrows to Ernakulam while the user types. // already narrows to Ernakulam while the user types.
if (filters.district !== undefined && filters.district !== '') { 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 !== '') { 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<ClientRow>(`SELECT * FROM client${where} ORDER BY name`, ...args) const rows = await db.all<ClientRow>(`SELECT * FROM client${where} ORDER BY name`, ...args)
return rows.map(toClient) return rows.map(toClient)

@ -35,6 +35,15 @@ describe('client support data (repo)', () => {
expect(await listClients(db)).toHaveLength(3) // no filter = everything, unchanged 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 () => { it('DB password: encrypted at rest, never in payloads, reveal decrypts + audits', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })

Loading…
Cancel
Save