Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions backend/shared/db/__tests__/softDelete.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
124 changes: 124 additions & 0 deletions backend/shared/db/softDelete.ts
Original file line number Diff line number Diff line change
@@ -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<Record<SoftDeleteTable, SoftDeleteHelper>> =
Object.fromEntries(
SOFT_DELETE_TABLES.map((table) => [table, new SoftDeleteHelper(table)]),
) as Readonly<Record<SoftDeleteTable, SoftDeleteHelper>>;

/**
* Patch a SELECT query to filter out soft-deleted rows.
* Appends `AND <table>.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}`;
}
94 changes: 94 additions & 0 deletions db/migrations/011_soft_delete_core_entities.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading