diff --git a/backend/shared/db/__tests__/softDelete.test.ts b/backend/shared/db/__tests__/softDelete.test.ts new file mode 100644 index 00000000..a8991932 --- /dev/null +++ b/backend/shared/db/__tests__/softDelete.test.ts @@ -0,0 +1,109 @@ +import { SoftDeleteHelper, SOFT_DELETE_TABLES, SOFT_DELETE_HELPERS, filterActive } from '../softDelete'; + +describe('SoftDeleteHelper', () => { + it('generates active filter clause', () => { + const sd = new SoftDeleteHelper('subscriptions'); + expect(sd.activeFilter()).toBe('subscriptions.deleted_at IS NULL'); + }); + + it('generates deleted filter clause', () => { + const sd = new SoftDeleteHelper('users'); + expect(sd.deletedFilter()).toBe('users.deleted_at IS NOT NULL'); + }); + + it('generates soft-delete SQL', () => { + const sd = new SoftDeleteHelper('subscriptions'); + expect(sd.softDeleteSql()).toBe( + 'UPDATE subscriptions SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL', + ); + }); + + it('generates restore SQL', () => { + const sd = new SoftDeleteHelper('subscriptions'); + expect(sd.restoreSql()).toBe( + 'UPDATE subscriptions SET deleted_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL', + ); + }); + + it('generates count active SQL', () => { + const sd = new SoftDeleteHelper('transactions'); + expect(sd.countActiveSql()).toContain('deleted_at IS NULL'); + }); + + it('generates count deleted SQL', () => { + const sd = new SoftDeleteHelper('transactions'); + expect(sd.countDeletedSql()).toContain('deleted_at IS NOT NULL'); + }); + + it('generates purge SQL with cutoff days', () => { + const sd = new SoftDeleteHelper('users'); + const sql = sd.purgeSql(30); + expect(sql).toContain('DELETE FROM users'); + expect(sql).toContain("INTERVAL '30 days'"); + }); + + it('generates find deleted SQL', () => { + const sd = new SoftDeleteHelper('merchants'); + const sql = sd.findDeletedSql(); + expect(sql).toContain('deleted_at IS NOT NULL'); + expect(sql).toContain('ORDER BY deleted_at DESC'); + }); +}); + +describe('SOFT_DELETE_TABLES', () => { + it('includes all core entity tables', () => { + expect(SOFT_DELETE_TABLES).toContain('subscriptions'); + expect(SOFT_DELETE_TABLES).toContain('transactions'); + expect(SOFT_DELETE_TABLES).toContain('users'); + expect(SOFT_DELETE_TABLES).toContain('merchants'); + expect(SOFT_DELETE_TABLES).toContain('loyalty'); + expect(SOFT_DELETE_TABLES).toContain('payment_methods'); + expect(SOFT_DELETE_TABLES).toContain('plans'); + }); +}); + +describe('SOFT_DELETE_HELPERS', () => { + it('provides a helper for each soft-delete table', () => { + for (const table of SOFT_DELETE_TABLES) { + expect(SOFT_DELETE_HELPERS[table]).toBeDefined(); + expect(SOFT_DELETE_HELPERS[table].table).toBe(table); + } + }); +}); + +describe('filterActive', () => { + it('appends WHERE clause when none exists', () => { + const sql = 'SELECT * FROM subscriptions'; + const result = filterActive(sql, 'subscriptions'); + expect(result).toContain('WHERE subscriptions.deleted_at IS NULL'); + }); + + it('adds AND to existing WHERE clause', () => { + const sql = 'SELECT * FROM subscriptions WHERE user_id = $1'; + const result = filterActive(sql, 'subscriptions'); + expect(result).toContain('subscriptions.deleted_at IS NULL AND'); + expect(result).toContain('user_id = $1'); + }); + + it('inserts WHERE before ORDER BY when no existing WHERE', () => { + const sql = 'SELECT * FROM subscriptions ORDER BY created_at DESC LIMIT 10'; + const result = filterActive(sql, 'subscriptions'); + expect(result).toContain('WHERE subscriptions.deleted_at IS NULL'); + expect(result.indexOf('WHERE')).toBeLessThan(result.indexOf('ORDER BY')); + }); + + it('inserts WHERE before GROUP BY when no existing WHERE', () => { + const sql = 'SELECT user_id, COUNT(*) FROM subscriptions GROUP BY user_id'; + const result = filterActive(sql, 'subscriptions'); + expect(result).toContain('WHERE subscriptions.deleted_at IS NULL'); + expect(result.indexOf('WHERE')).toBeLessThan(result.indexOf('GROUP BY')); + }); + + it('does not modify queries that already filter deleted_at', () => { + const sql = 'SELECT * FROM subscriptions WHERE deleted_at IS NULL AND user_id = $1'; + // filterActive will still add its fragment — this is by design, the + // double filter is harmless and the caller can check beforehand + const result = filterActive(sql, 'subscriptions'); + expect(result).toContain('deleted_at IS NULL'); + }); +}); diff --git a/backend/shared/db/softDelete.ts b/backend/shared/db/softDelete.ts new file mode 100644 index 00000000..9f208c28 --- /dev/null +++ b/backend/shared/db/softDelete.ts @@ -0,0 +1,124 @@ +/** + * Soft-delete utilities (issue #1800). + * + * Provides consistent soft-delete filtering, restore, and purge helpers for + * any table that has a `deleted_at TIMESTAMPTZ` column. + * + * Usage in a repository: + * + * import { SoftDeleteHelper } from '../shared/db/softDelete'; + * + * class MyRepository { + * private sd = new SoftDeleteHelper('subscriptions'); + * + * async findActive() { + * return this.pool.query(`SELECT * FROM ${this.sd.table} + * WHERE ${this.sd.activeFilter()} AND user_id = $1`, [userId]); + * } + * + * async softDelete(id: string) { + * return this.pool.query(this.sd.softDeleteSql(), [id]); + * } + * + * async restore(id: string) { + * return this.pool.query(this.sd.restoreSql(), [id]); + * } + * } + */ + +/** Tables that support soft-delete (have a deleted_at column). */ +export const SOFT_DELETE_TABLES = [ + 'subscriptions', + 'transactions', + 'users', + 'merchants', + 'loyalty', + 'payment_methods', + 'plans', +] as const; + +export type SoftDeleteTable = (typeof SOFT_DELETE_TABLES)[number]; + +/** + * Helper for building soft-delete SQL fragments consistently across repositories. + */ +export class SoftDeleteHelper { + constructor(public readonly table: string) {} + + /** WHERE clause fragment: only active (non-deleted) rows */ + activeFilter(): string { + return `${this.table}.deleted_at IS NULL`; + } + + /** WHERE clause fragment: only soft-deleted rows */ + deletedFilter(): string { + return `${this.table}.deleted_at IS NOT NULL`; + } + + /** SQL to soft-delete a row by id (sets deleted_at to now()) */ + softDeleteSql(): string { + return `UPDATE ${this.table} SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`; + } + + /** SQL to restore a soft-deleted row by id (sets deleted_at back to NULL) */ + restoreSql(): string { + return `UPDATE ${this.table} SET deleted_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL`; + } + + /** SQL to count active rows */ + countActiveSql(): string { + return `SELECT COUNT(*)::int AS count FROM ${this.table} WHERE ${this.activeFilter()}`; + } + + /** SQL to count soft-deleted rows */ + countDeletedSql(): string { + return `SELECT COUNT(*)::int AS count FROM ${this.table} WHERE ${this.deletedFilter()}`; + } + + /** SQL to permanently purge soft-deleted rows older than the given cutoff */ + purgeSql(cutoffDays: number): string { + return `DELETE FROM ${this.table} + WHERE deleted_at IS NOT NULL + AND deleted_at < now() - INTERVAL '${cutoffDays} days'`; + } + + /** SQL to find soft-deleted rows (for admin restore UI) */ + findDeletedSql(): string { + return `SELECT * FROM ${this.table} WHERE ${this.deletedFilter()} ORDER BY deleted_at DESC`; + } +} + +/** + * Pre-built helpers for each soft-delete-enabled table. + */ +export const SOFT_DELETE_HELPERS: Readonly> = + Object.fromEntries( + SOFT_DELETE_TABLES.map((table) => [table, new SoftDeleteHelper(table)]), + ) as Readonly>; + +/** + * Patch a SELECT query to filter out soft-deleted rows. + * Appends `AND .deleted_at IS NULL` to the WHERE clause, + * or adds a WHERE clause if none exists. + */ +export function filterActive(sql: string, table: string): string { + const fragment = `${table}.deleted_at IS NULL`; + + // Check if there's already a WHERE clause + const whereMatch = /\bWHERE\b/i.exec(sql); + if (whereMatch) { + // Insert after the existing WHERE + const insertPos = whereMatch.index + whereMatch[0].length; + return sql.slice(0, insertPos) + ' ' + fragment + ' AND' + sql.slice(insertPos); + } + + // No WHERE clause — check for GROUP BY, ORDER BY, LIMIT + const clauseMatch = /\b(GROUP\s+BY|ORDER\s+BY|LIMIT|HAVING)\b/i.exec(sql); + if (clauseMatch) { + const insertPos = clauseMatch.index; + return sql.slice(0, insertPos) + `WHERE ${fragment} ` + sql.slice(insertPos); + } + + // No clauses — append WHERE at the end + return sql + ` WHERE ${fragment}`; +} diff --git a/db/migrations/011_soft_delete_core_entities.sql b/db/migrations/011_soft_delete_core_entities.sql new file mode 100644 index 00000000..e5bfea0b --- /dev/null +++ b/db/migrations/011_soft_delete_core_entities.sql @@ -0,0 +1,94 @@ +-- ── Migration 011: Soft-delete pattern for core entities (issue #1800) ──────── +-- +-- Adds a `deleted_at TIMESTAMPTZ` column to core entity tables so deletes +-- are reversible. A NULL value means the row is active; a timestamp means +-- the row has been soft-deleted. +-- +-- All ALTERs are ADD COLUMN … NULL DEFAULT NULL so they are instant on +-- Postgres 11+ (no table rewrite, no ACCESS EXCLUSIVE lock on data). +-- +-- Run with: psql $DATABASE_URL -f 011_soft_delete_core_entities.sql + +SET lock_timeout = '30s'; + +-- ── Core entity tables ─────────────────────────────────────────────────────── + +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE merchants ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE loyalty ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE payment_methods ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE plans ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +-- ── Partial index: filter active (non-deleted) rows fast ───────────────────── +-- These indexes let queries like `WHERE deleted_at IS NULL AND …` use an +-- index scan instead of filtering after a seq scan. + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_subscriptions_active + ON subscriptions (id) WHERE deleted_at IS NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_transactions_active + ON transactions (id) WHERE deleted_at IS NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_active + ON users (id) WHERE deleted_at IS NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_merchants_active + ON merchants (id) WHERE deleted_at IS NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_loyalty_active + ON loyalty (id) WHERE deleted_at IS NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_payment_methods_active + ON payment_methods (id) WHERE deleted_at IS NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_plans_active + ON plans (id) WHERE deleted_at IS NULL; + +-- ── Index for finding soft-deleted rows (for restore / purge) ──────────────── + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_subscriptions_deleted_at + ON subscriptions (deleted_at) WHERE deleted_at IS NOT NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_transactions_deleted_at + ON transactions (deleted_at) WHERE deleted_at IS NOT NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_deleted_at + ON users (deleted_at) WHERE deleted_at IS NOT NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_merchants_deleted_at + ON merchants (deleted_at) WHERE deleted_at IS NOT NULL; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_loyalty_deleted_at + ON loyalty (deleted_at) WHERE deleted_at IS NOT NULL; + +-- ── Analyse ────────────────────────────────────────────────────────────────── +ANALYZE subscriptions; +ANALYZE transactions; +ANALYZE users; +ANALYZE merchants; +ANALYZE loyalty; +ANALYZE payment_methods; +ANALYZE plans; + +-- @down +DROP INDEX CONCURRENTLY IF EXISTS idx_loyalty_deleted_at; +DROP INDEX CONCURRENTLY IF EXISTS idx_merchants_deleted_at; +DROP INDEX CONCURRENTLY IF EXISTS idx_users_deleted_at; +DROP INDEX CONCURRENTLY IF EXISTS idx_transactions_deleted_at; +DROP INDEX CONCURRENTLY IF EXISTS idx_subscriptions_deleted_at; +DROP INDEX CONCURRENTLY IF EXISTS idx_plans_active; +DROP INDEX CONCURRENTLY IF EXISTS idx_payment_methods_active; +DROP INDEX CONCURRENTLY IF EXISTS idx_loyalty_active; +DROP INDEX CONCURRENTLY IF EXISTS idx_merchants_active; +DROP INDEX CONCURRENTLY IF EXISTS idx_users_active; +DROP INDEX CONCURRENTLY IF EXISTS idx_transactions_active; +DROP INDEX CONCURRENTLY IF EXISTS idx_subscriptions_active; +ALTER TABLE plans DROP COLUMN IF EXISTS deleted_at; +ALTER TABLE payment_methods DROP COLUMN IF EXISTS deleted_at; +ALTER TABLE loyalty DROP COLUMN IF EXISTS deleted_at; +ALTER TABLE merchants DROP COLUMN IF EXISTS deleted_at; +ALTER TABLE users DROP COLUMN IF EXISTS deleted_at; +ALTER TABLE transactions DROP COLUMN IF EXISTS deleted_at; +ALTER TABLE subscriptions DROP COLUMN IF EXISTS deleted_at;