-- Audit log table CREATE TABLE app.audit_log ( audit_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), table_name TEXT NOT NULL, record_id TEXT NOT NULL, action TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE' user_id UUID, -- from session variable, nullable for system actions changed_fields JSONB, -- UPDATE: {field: {old, new}}; INSERT: full row; DELETE: full row occurred_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_audit_log_table_record ON app.audit_log(table_name, record_id); CREATE INDEX idx_audit_log_user ON app.audit_log(user_id); CREATE INDEX idx_audit_log_occurred ON app.audit_log(occurred_at); -- Generic trigger function CREATE OR REPLACE FUNCTION app.audit_trigger_func() RETURNS TRIGGER AS $$ DECLARE _user_id UUID; _record_id TEXT; _changed JSONB; _old JSONB; _new JSONB; _key TEXT; BEGIN -- Get current user from session variable (set per-request by app) BEGIN _user_id := current_setting('app.current_user_id', true)::UUID; EXCEPTION WHEN OTHERS THEN _user_id := NULL; END; IF TG_OP = 'DELETE' THEN _record_id := (row_to_json(OLD) ->> TG_ARGV[0])::TEXT; INSERT INTO app.audit_log (table_name, record_id, action, user_id, changed_fields) VALUES (TG_TABLE_NAME, _record_id, 'DELETE', _user_id, row_to_json(OLD)::JSONB); RETURN OLD; ELSIF TG_OP = 'INSERT' THEN _record_id := (row_to_json(NEW) ->> TG_ARGV[0])::TEXT; INSERT INTO app.audit_log (table_name, record_id, action, user_id, changed_fields) VALUES (TG_TABLE_NAME, _record_id, 'INSERT', _user_id, row_to_json(NEW)::JSONB); RETURN NEW; ELSIF TG_OP = 'UPDATE' THEN _record_id := (row_to_json(NEW) ->> TG_ARGV[0])::TEXT; _old := row_to_json(OLD)::JSONB; _new := row_to_json(NEW)::JSONB; _changed := '{}'::JSONB; FOR _key IN SELECT jsonb_object_keys(_new) LOOP IF _key IN ('updated_at', 'created_at') THEN CONTINUE; END IF; IF (_old ->> _key) IS DISTINCT FROM (_new ->> _key) THEN _changed := _changed || jsonb_build_object( _key, jsonb_build_object('old', _old -> _key, 'new', _new -> _key) ); END IF; END LOOP; -- Skip if nothing actually changed IF _changed = '{}'::JSONB THEN RETURN NEW; END IF; INSERT INTO app.audit_log (table_name, record_id, action, user_id, changed_fields) VALUES (TG_TABLE_NAME, _record_id, 'UPDATE', _user_id, _changed); RETURN NEW; END IF; RETURN NULL; END; $$ LANGUAGE plpgsql; -- Helper to attach audit trigger to a table CREATE OR REPLACE FUNCTION app.enable_audit(target_table TEXT, pk_column TEXT DEFAULT 'id') RETURNS VOID AS $$ DECLARE trigger_name TEXT; BEGIN trigger_name := 'audit_' || replace(target_table, '"', ''); EXECUTE format( 'CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON app.%s FOR EACH ROW EXECUTE FUNCTION app.audit_trigger_func(%L)', trigger_name, target_table, pk_column ); END; $$ LANGUAGE plpgsql; -- Enable audit on existing tables SELECT app.enable_audit('"user"', 'user_id'); SELECT app.enable_audit('user_profile', 'profile_id');