diff --git a/backend/shared/db/__tests__/auditColumns.test.ts b/backend/shared/db/__tests__/auditColumns.test.ts new file mode 100644 index 00000000..42465114 --- /dev/null +++ b/backend/shared/db/__tests__/auditColumns.test.ts @@ -0,0 +1,116 @@ +import { AuditContext, withAuditColumns, AUDIT_TABLES } from '../auditColumns'; + +describe('AuditContext', () => { + it('creates from user request', () => { + const ctx = AuditContext.fromRequest({ userId: 'user-123' }); + expect(ctx.actorId).toBe('user-123'); + expect(ctx.actorType).toBe('user'); + }); + + it('creates from nested user object', () => { + const ctx = AuditContext.fromRequest({ user: { id: 'user-456' } }); + expect(ctx.actorId).toBe('user-456'); + expect(ctx.actorType).toBe('user'); + }); + + it('creates from API key request', () => { + const ctx = AuditContext.fromRequest({ apiKey: { id: 'key-789' } }); + expect(ctx.actorId).toBe('key-789'); + expect(ctx.actorType).toBe('api_key'); + }); + + it('defaults to system when no actor is present', () => { + const ctx = AuditContext.fromRequest({}); + expect(ctx.actorId).toBe('system'); + expect(ctx.actorType).toBe('system'); + }); + + it('system() creates a system context', () => { + const ctx = AuditContext.system(); + expect(ctx.actorId).toBe('system'); + expect(ctx.actorType).toBe('system'); + }); + + it('anonymous() creates an anonymous context', () => { + const ctx = AuditContext.anonymous(); + expect(ctx.actorId).toBe('anonymous'); + expect(ctx.actorType).toBe('system'); + }); +}); + +describe('withAuditColumns.insert', () => { + it('builds INSERT with created_by and updated_by', () => { + const ctx = new AuditContext('user-123', 'user'); + const { sql, params } = withAuditColumns.insert( + 'subscriptions', + { user_id: 'u1', name: 'Pro', amount: 99 }, + ctx, + ); + + expect(sql).toContain('INSERT INTO subscriptions'); + expect(sql).toContain('created_by'); + expect(sql).toContain('updated_by'); + expect(params).toEqual(['u1', 'Pro', 99, 'user-123', 'user-123']); + }); + + it('generates correct placeholder numbering', () => { + const ctx = AuditContext.system(); + const { sql } = withAuditColumns.insert( + 'users', + { email: 'a@b.com', name: 'Alice' }, + ctx, + ); + expect(sql).toContain('$1'); + expect(sql).toContain('$2'); + expect(sql).toContain('$3'); + expect(sql).toContain('$4'); + }); +}); + +describe('withAuditColumns.update', () => { + it('builds UPDATE with updated_by', () => { + const ctx = new AuditContext('user-456', 'user'); + const { sql, params } = withAuditColumns.update( + 'subscriptions', + { status: 'active', amount: 199 }, + 'id = $1', + ['sub-123'], + ctx, + ); + + expect(sql).toContain('UPDATE subscriptions SET'); + expect(sql).toContain('status ='); + expect(sql).toContain('amount ='); + expect(sql).toContain('updated_by ='); + expect(sql).toContain('WHERE id ='); + expect(params).toContain('user-456'); + expect(params).toContain('sub-123'); + }); + + it('handles multiple WHERE params', () => { + const ctx = AuditContext.system(); + const { sql, params } = withAuditColumns.update( + 'transactions', + { status: 'completed' }, + 'user_id = $1 AND id = $2', + ['u1', 't1'], + ctx, + ); + + expect(params).toEqual(['completed', 'system', 'u1', 't1']); + expect(sql).toContain('WHERE user_id ='); + expect(sql).toContain('AND id ='); + }); +}); + +describe('AUDIT_TABLES', () => { + it('includes all core entity tables', () => { + expect(AUDIT_TABLES).toContain('subscriptions'); + expect(AUDIT_TABLES).toContain('transactions'); + expect(AUDIT_TABLES).toContain('users'); + expect(AUDIT_TABLES).toContain('merchants'); + expect(AUDIT_TABLES).toContain('loyalty'); + expect(AUDIT_TABLES).toContain('payment_methods'); + expect(AUDIT_TABLES).toContain('plans'); + }); +}); diff --git a/backend/shared/db/auditColumns.ts b/backend/shared/db/auditColumns.ts new file mode 100644 index 00000000..69a4f7e3 --- /dev/null +++ b/backend/shared/db/auditColumns.ts @@ -0,0 +1,138 @@ +/** + * Audit column utilities (issue #1801). + * + * Provides helpers for populating `created_by` and `updated_by` columns + * from the request context, and for building INSERT/UPDATE statements + * that include audit attribution. + * + * Usage: + * + * import { AuditContext, withAuditColumns } from '../shared/db/auditColumns'; + * + * // In a request handler: + * const ctx = AuditContext.fromRequest(req); // extracts actorId + * + * // In a repository: + * const { sql, params } = withAuditColumns.insert('subscriptions', { + * user_id: userId, name, amount, ... + * }, ctx); + * await pool.query(sql, params); + */ + +/** Tables that have created_by / updated_by columns. */ +export const AUDIT_TABLES = [ + 'subscriptions', + 'transactions', + 'users', + 'merchants', + 'loyalty', + 'payment_methods', + 'plans', +] as const; + +export type AuditTable = (typeof AUDIT_TABLES)[number]; + +/** + * Carries the actor identity through the request → repository path. + */ +export class AuditContext { + constructor( + public readonly actorId: string, + public readonly actorType: 'user' | 'system' | 'api_key' = 'user', + ) {} + + /** Build from an HTTP request (extracts user ID from auth header / session). */ + static fromRequest(req: { + userId?: string; + user?: { id?: string }; + apiKey?: { id?: string }; + }): AuditContext { + const actorId = + req.userId ?? + req.user?.id ?? + req.apiKey?.id ?? + 'system'; + const actorType = req.apiKey?.id ? 'api_key' : actorId === 'system' ? 'system' : 'user'; + return new AuditContext(actorId, actorType as 'user' | 'system' | 'api_key'); + } + + /** System context for background jobs / cron. */ + static system(): AuditContext { + return new AuditContext('system', 'system'); + } + + /** Anonymous context for unauthenticated operations (e.g., signup). */ + static anonymous(): AuditContext { + return new AuditContext('anonymous', 'system'); + } +} + +/** + * Build INSERT and UPDATE statements that include created_by / updated_by. + */ +export const withAuditColumns = { + /** + * Build an INSERT statement with created_by and updated_by. + * + * @param table target table + * @param data column → value map (without audit columns) + * @param ctx audit context providing the actor ID + * @returns { sql, params } ready for pool.query(sql, params) + */ + insert( + table: string, + data: Record, + ctx: AuditContext, + ): { sql: string; params: unknown[] } { + const columns = [...Object.keys(data), 'created_by', 'updated_by']; + const values = [...Object.values(data), ctx.actorId, ctx.actorId]; + const placeholders = columns.map((_, i) => `$${i + 1}`).join(', '); + + return { + sql: `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`, + params: values, + }; + }, + + /** + * Build an UPDATE statement that sets updated_by. + * + * @param table target table + * @param data column → value map (without audit columns) + * @param whereClause WHERE clause (e.g., "id = $1") + * @param whereParams params for the WHERE clause + * @param ctx audit context providing the actor ID + * @returns { sql, params } ready for pool.query(sql, params) + */ + update( + table: string, + data: Record, + whereClause: string, + whereParams: unknown[], + ctx: AuditContext, + ): { sql: string; params: unknown[] } { + const entries = Object.entries(data); + const setColumns = [...entries.map(([col]) => `${col} = $%`), 'updated_by = $%']; + const params: unknown[] = []; + + let placeholderIdx = 1; + const setClause = setColumns + .map((s) => s.replace('$%', `$${placeholderIdx++}`)) + .join(', '); + + params.push(...entries.map(([, val]) => val)); + params.push(ctx.actorId); + + // Append WHERE params + const whereClauseWithPlaceholders = whereClause.replace( + /\$(\d+)/g, + (_, n) => `$${Number(n) + params.length - whereParams.length}`, + ); + params.push(...whereParams); + + return { + sql: `UPDATE ${table} SET ${setClause} WHERE ${whereClauseWithPlaceholders}`, + params, + }; + }, +}; diff --git a/db/migrations/012_audit_columns_created_updated_by.sql b/db/migrations/012_audit_columns_created_updated_by.sql new file mode 100644 index 00000000..ab0ce020 --- /dev/null +++ b/db/migrations/012_audit_columns_created_updated_by.sql @@ -0,0 +1,139 @@ +-- ── Migration 012: Audit columns — created/updated by (issue #1801) ────────── +-- +-- Adds `created_by TEXT` and `updated_by TEXT` columns to core entity tables +-- so every row records who created it and who last modified it. +-- +-- All ALTERs are ADD COLUMN … NULL so they are instant on Postgres 11+ +-- (no table rewrite, no ACCESS EXCLUSIVE lock on data). +-- +-- Run with: psql $DATABASE_URL -f 012_audit_columns_created_updated_by.sql + +SET lock_timeout = '30s'; + +-- ── Core entity tables ─────────────────────────────────────────────────────── + +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS created_by TEXT; +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS updated_by TEXT; + +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS created_by TEXT; +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS updated_by TEXT; + +ALTER TABLE users ADD COLUMN IF NOT EXISTS created_by TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_by TEXT; + +ALTER TABLE merchants ADD COLUMN IF NOT EXISTS created_by TEXT; +ALTER TABLE merchants ADD COLUMN IF NOT EXISTS updated_by TEXT; + +ALTER TABLE loyalty ADD COLUMN IF NOT EXISTS created_by TEXT; +ALTER TABLE loyalty ADD COLUMN IF NOT EXISTS updated_by TEXT; + +ALTER TABLE payment_methods ADD COLUMN IF NOT EXISTS created_by TEXT; +ALTER TABLE payment_methods ADD COLUMN IF NOT EXISTS updated_by TEXT; + +ALTER TABLE plans ADD COLUMN IF NOT EXISTS created_by TEXT; +ALTER TABLE plans ADD COLUMN IF NOT EXISTS updated_by TEXT; + +-- ── Indexes for querying by creator/updater ────────────────────────────────── + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_subscriptions_created_by + ON subscriptions (created_by) WHERE created_by IS NOT NULL; +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_subscriptions_updated_by + ON subscriptions (updated_by) WHERE updated_by IS NOT NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_transactions_created_by + ON transactions (created_by) WHERE created_by IS NOT NULL; +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_transactions_updated_by + ON transactions (updated_by) WHERE updated_by IS NOT NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_created_by + ON users (created_by) WHERE created_by IS NOT NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_merchants_created_by + ON merchants (created_by) WHERE created_by IS NOT NULL; + +-- ── Trigger function: auto-update updated_at and updated_by ───────────────── +-- This trigger fires on UPDATE and sets updated_at = now() automatically. +-- updated_by must be set by the application; the trigger only updates +-- updated_at so the application doesn't have to manage timestamps manually. + +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_subscriptions_updated_at ON subscriptions; +CREATE TRIGGER trg_subscriptions_updated_at + BEFORE UPDATE ON subscriptions + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_transactions_updated_at ON transactions; +CREATE TRIGGER trg_transactions_updated_at + BEFORE UPDATE ON transactions + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_users_updated_at ON users; +CREATE TRIGGER trg_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_merchants_updated_at ON merchants; +CREATE TRIGGER trg_merchants_updated_at + BEFORE UPDATE ON merchants + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_loyalty_updated_at ON loyalty; +CREATE TRIGGER trg_loyalty_updated_at + BEFORE UPDATE ON loyalty + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_payment_methods_updated_at ON payment_methods; +CREATE TRIGGER trg_payment_methods_updated_at + BEFORE UPDATE ON payment_methods + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_plans_updated_at ON plans; +CREATE TRIGGER trg_plans_updated_at + BEFORE UPDATE ON plans + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- ── Analyse ────────────────────────────────────────────────────────────────── +ANALYZE subscriptions; +ANALYZE transactions; +ANALYZE users; +ANALYZE merchants; +ANALYZE loyalty; +ANALYZE payment_methods; +ANALYZE plans; + +-- @down +DROP TRIGGER IF EXISTS trg_plans_updated_at ON plans; +DROP TRIGGER IF EXISTS trg_payment_methods_updated_at ON payment_methods; +DROP TRIGGER IF EXISTS trg_loyalty_updated_at ON loyalty; +DROP TRIGGER IF EXISTS trg_merchants_updated_at ON merchants; +DROP TRIGGER IF EXISTS trg_users_updated_at ON users; +DROP TRIGGER IF EXISTS trg_transactions_updated_at ON transactions; +DROP TRIGGER IF EXISTS trg_subscriptions_updated_at ON subscriptions; +DROP FUNCTION IF EXISTS set_updated_at(); +DROP INDEX CONCURRENTLY IF EXISTS idx_merchants_created_by; +DROP INDEX CONCURRENTLY IF EXISTS idx_users_created_by; +DROP INDEX CONCURRENTLY IF EXISTS idx_transactions_updated_by; +DROP INDEX CONCURRENTLY IF EXISTS idx_transactions_created_by; +DROP INDEX CONCURRENTLY IF EXISTS idx_subscriptions_updated_by; +DROP INDEX CONCURRENTLY IF EXISTS idx_subscriptions_created_by; +ALTER TABLE plans DROP COLUMN IF EXISTS updated_by; +ALTER TABLE plans DROP COLUMN IF EXISTS created_by; +ALTER TABLE payment_methods DROP COLUMN IF EXISTS updated_by; +ALTER TABLE payment_methods DROP COLUMN IF EXISTS created_by; +ALTER TABLE loyalty DROP COLUMN IF EXISTS updated_by; +ALTER TABLE loyalty DROP COLUMN IF EXISTS created_by; +ALTER TABLE merchants DROP COLUMN IF EXISTS updated_by; +ALTER TABLE merchants DROP COLUMN IF EXISTS created_by; +ALTER TABLE users DROP COLUMN IF EXISTS updated_by; +ALTER TABLE users DROP COLUMN IF EXISTS created_by; +ALTER TABLE transactions DROP COLUMN IF EXISTS updated_by; +ALTER TABLE transactions DROP COLUMN IF EXISTS created_by; +ALTER TABLE subscriptions DROP COLUMN IF EXISTS updated_by; +ALTER TABLE subscriptions DROP COLUMN IF EXISTS created_by;