From Hermes' agent/default/soft-delete-audit branch: - Add migration 004_audit_logging.sql (audit_logs table, trigger function, triggers on loads/shippers/vehicles/payments/portal_users, set_audit_user() helper function) - Improved: uses IF NOT EXISTS, AFTER triggers, user session context var, distinguishes SOFT_DELETE vs HARD_DELETE, notes field New: - GET /audit-logs (admin-only, filterable by table/action, paginated) - GET /audit-logs/:id (detail view with before/after JSON) - Audit Logs link in sidebar Keeps all existing OWL code: CI/CD, Pino, Prometheus, tests, cache-busting, debounced search, ESLint, Prettier
109 lines
4.6 KiB
PL/PgSQL
109 lines
4.6 KiB
PL/PgSQL
-- ============================================================
|
|
-- FreightDesk — Migration 004: Audit Logging
|
|
-- Adapted from Hermes agent's soft-delete-audit branch
|
|
-- Adds audit_logs table + triggers for core tables
|
|
-- ============================================================
|
|
|
|
-- ============================================================
|
|
-- AUDIT LOG TABLE
|
|
-- ============================================================
|
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
action TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
|
|
table_name TEXT NOT NULL, -- e.g., 'loads', 'shippers'
|
|
row_id UUID, -- UUID of the affected row
|
|
before_json JSONB, -- state before change
|
|
after_json JSONB, -- state after change
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
user_id UUID, -- admin who made change (nullable)
|
|
notes TEXT
|
|
);
|
|
|
|
-- Indexes for fast lookup
|
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_table ON audit_logs(table_name);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_time ON audit_logs(created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_row ON audit_logs(table_name, row_id);
|
|
|
|
-- ============================================================
|
|
-- AUDIT TRIGGER FUNCTION
|
|
-- Captures INSERT, UPDATE, DELETE on core tables
|
|
-- Uses PostgreSQL session variable app.current_user_id for user tracking
|
|
-- ============================================================
|
|
CREATE OR REPLACE FUNCTION fn_audit_trigger()
|
|
RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
v_user_id UUID;
|
|
BEGIN
|
|
-- Get user_id from session variable (set by app before DB ops)
|
|
BEGIN
|
|
v_user_id := current_setting('app.current_user_id', true)::UUID;
|
|
EXCEPTION WHEN OTHERS THEN
|
|
v_user_id := NULL;
|
|
END;
|
|
|
|
IF TG_OP = 'INSERT' THEN
|
|
INSERT INTO audit_logs(action, table_name, row_id, after_json, user_id)
|
|
VALUES ('INSERT', TG_TABLE_NAME, NEW.id, to_jsonb(NEW), v_user_id);
|
|
RETURN NEW;
|
|
|
|
ELSIF TG_OP = 'UPDATE' THEN
|
|
-- Skip if only deleted_at changed (soft-delete is logged separately)
|
|
IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
|
|
INSERT INTO audit_logs(action, table_name, row_id, before_json, after_json, user_id, notes)
|
|
VALUES ('SOFT_DELETE', TG_TABLE_NAME, OLD.id, to_jsonb(OLD), to_jsonb(NEW), v_user_id, 'Soft delete via trigger');
|
|
ELSE
|
|
INSERT INTO audit_logs(action, table_name, row_id, before_json, after_json, user_id)
|
|
VALUES ('UPDATE', TG_TABLE_NAME, NEW.id, to_jsonb(OLD), to_jsonb(NEW), v_user_id);
|
|
END IF;
|
|
RETURN NEW;
|
|
|
|
ELSIF TG_OP = 'DELETE' THEN
|
|
INSERT INTO audit_logs(action, table_name, row_id, before_json, user_id, notes)
|
|
VALUES ('HARD_DELETE', TG_TABLE_NAME, OLD.id, to_jsonb(OLD), v_user_id, 'Hard delete (bypasses soft-delete)');
|
|
RETURN OLD;
|
|
END IF;
|
|
|
|
RETURN NULL;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- ============================================================
|
|
-- ATTACH TRIGGERS TO CORE TABLES
|
|
-- ============================================================
|
|
DROP TRIGGER IF EXISTS trg_audit_loads ON loads;
|
|
CREATE TRIGGER trg_audit_loads
|
|
AFTER INSERT OR UPDATE OR DELETE ON loads
|
|
FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger();
|
|
|
|
DROP TRIGGER IF EXISTS trg_audit_shippers ON shippers;
|
|
CREATE TRIGGER trg_audit_shippers
|
|
AFTER INSERT OR UPDATE OR DELETE ON shippers
|
|
FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger();
|
|
|
|
DROP TRIGGER IF EXISTS trg_audit_vehicles ON vehicles;
|
|
CREATE TRIGGER trg_audit_vehicles
|
|
AFTER INSERT OR UPDATE OR DELETE ON vehicles
|
|
FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger();
|
|
|
|
DROP TRIGGER IF EXISTS trg_audit_payments ON payments;
|
|
CREATE TRIGGER trg_audit_payments
|
|
AFTER INSERT OR UPDATE OR DELETE ON payments
|
|
FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger();
|
|
|
|
DROP TRIGGER IF EXISTS trg_audit_portal_users ON portal_users;
|
|
CREATE TRIGGER trg_audit_portal_users
|
|
AFTER INSERT OR UPDATE OR DELETE ON portal_users
|
|
FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger();
|
|
|
|
-- ============================================================
|
|
-- HELPER: Function to set current user for audit context
|
|
-- Call this from Express middleware before DB operations:
|
|
-- await supabase.rpc('set_audit_user', { user_id: req.session.user.id })
|
|
-- ============================================================
|
|
CREATE OR REPLACE FUNCTION set_audit_user(user_id UUID)
|
|
RETURNS VOID AS $$
|
|
BEGIN
|
|
PERFORM set_config('app.current_user_id', user_id::TEXT, false);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|