-- ============================================================ -- 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;