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
116 changes: 116 additions & 0 deletions backend/shared/db/__tests__/auditColumns.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
138 changes: 138 additions & 0 deletions backend/shared/db/auditColumns.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
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<string, unknown>,
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,
};
},
};
139 changes: 139 additions & 0 deletions db/migrations/012_audit_columns_created_updated_by.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading