From 795cc86b5a9e5f642459fad619efc0c1910c1879 Mon Sep 17 00:00:00 2001 From: FreightDesk Date: Sun, 7 Jun 2026 20:03:23 +0000 Subject: [PATCH] [OWL] Audit logging: cherry-pick Hermes' audit SQL, add routes + views 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 --- supabase/migrations/004_audit_logging.sql | 109 ++++++++++++++++++++++ webapp/src/routes/audit.js | 50 ++++++++++ webapp/src/server.js | 1 + webapp/src/views/pages/audit/detail.ejs | 68 ++++++++++++++ webapp/src/views/pages/audit/list.ejs | 93 ++++++++++++++++++ webapp/src/views/partials/header.ejs | 1 + 6 files changed, 322 insertions(+) create mode 100644 supabase/migrations/004_audit_logging.sql create mode 100644 webapp/src/routes/audit.js create mode 100644 webapp/src/views/pages/audit/detail.ejs create mode 100644 webapp/src/views/pages/audit/list.ejs diff --git a/supabase/migrations/004_audit_logging.sql b/supabase/migrations/004_audit_logging.sql new file mode 100644 index 0000000..11bc401 --- /dev/null +++ b/supabase/migrations/004_audit_logging.sql @@ -0,0 +1,109 @@ +-- ============================================================ +-- 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; diff --git a/webapp/src/routes/audit.js b/webapp/src/routes/audit.js new file mode 100644 index 0000000..0b0e2b6 --- /dev/null +++ b/webapp/src/routes/audit.js @@ -0,0 +1,50 @@ +const express = require('express'); +const router = express.Router(); +const { requireAuth, requireRole } = require('../middleware/auth'); +const supabase = require('../services/supabase'); +const { asyncHandler } = require('../middleware/security'); + +// GET /audit-logs — view audit trail (admin only) +router.get('/', requireAuth, requireRole('admin'), asyncHandler(async (req, res) => { + const { table, user_id, action, page = 1, limit = 50 } = req.query; + const offset = (page - 1) * limit; + + let query = supabase + .from('audit_logs') + .select('*', { count: 'exact' }) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1); + + if (table) query = query.eq('table_name', table); + if (user_id) query = query.eq('user_id', user_id); + if (action) query = query.eq('action', action); + + const { data: logs, count, error } = await query; + + if (error) throw error; + + const totalPages = Math.ceil(count / limit); + + res.render('pages/audit/list', { + logs: logs || [], + page: parseInt(page), + totalPages, + total: count, + filters: { table, user_id, action }, + }); +})); + +// GET /audit-logs/:id — single log detail +router.get('/:id', requireAuth, requireRole('admin'), asyncHandler(async (req, res) => { + const { data: log, error } = await supabase + .from('audit_logs') + .select('*') + .eq('id', req.params.id) + .single(); + + if (error) throw error; + + res.render('pages/audit/detail', { log }); +})); + +module.exports = router; diff --git a/webapp/src/server.js b/webapp/src/server.js index 394837f..bc80c9e 100644 --- a/webapp/src/server.js +++ b/webapp/src/server.js @@ -204,6 +204,7 @@ app.use('/shippers', require('./routes/shippers')); app.use('/vehicles', require('./routes/vehicles')); app.use('/payments', require('./routes/payments')); app.use('/reports', require('./routes/reports')); +app.use('/audit-logs', require('./routes/audit')); // Health check app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() })); diff --git a/webapp/src/views/pages/audit/detail.ejs b/webapp/src/views/pages/audit/detail.ejs new file mode 100644 index 0000000..7c526af --- /dev/null +++ b/webapp/src/views/pages/audit/detail.ejs @@ -0,0 +1,68 @@ +<%- include('../partials/header', { activeMenu: 'audit' }) %> + + + +
+
+
+
+ + <%= log.action %> +
+
+ + <%= log.table_name %> +
+
+ + <%= log.row_id || '—' %> +
+
+ + <%= new Date(log.created_at).toLocaleString('en-IN') %> +
+
+ + <%= log.user_id || 'System' %> +
+ <% if (log.notes) { %> +
+ + <%= log.notes %> +
+ <% } %> +
+
+
+ +<% if (log.before_json) { %> +
+
+

🗂 Before (Old Values)

+
+
+
<%= JSON.stringify(log.before_json, null, 2) %>
+
+
+<% } %> + +<% if (log.after_json) { %> +
+
+

🗃 After (New Values)

+
+
+
<%= JSON.stringify(log.after_json, null, 2) %>
+
+
+<% } %> + +<%- include('../partials/footer') %> diff --git a/webapp/src/views/pages/audit/list.ejs b/webapp/src/views/pages/audit/list.ejs new file mode 100644 index 0000000..c088269 --- /dev/null +++ b/webapp/src/views/pages/audit/list.ejs @@ -0,0 +1,93 @@ +<%- include('../partials/header', { activeMenu: 'audit' }) %> + + + + +
+
+
+
+ + +
+
+ + +
+
+ + Clear +
+
+
+
+ + +
+
+ <% if (!logs || logs.length === 0) { %> +

No audit logs found matching your filters.

+ <% } else { %> +

Showing <%= logs.length %> of <%= total %> logs (page <%= page %> of <%= totalPages %>)

+
+ + + + + + + + + + + + <% for (const log of logs) { %> + + + + + + + + <% } %> + +
TimeActionTableRow IDDetails
<%= new Date(log.created_at).toLocaleString('en-IN', { dateStyle: 'short', timeStyle: 'short' }) %> + + <%= log.action %> + + <%= log.table_name %><%= log.row_id ? log.row_id.slice(0,8) + '...' : '—' %>View
+
+ + <% if (totalPages > 1) { %> + + <% } %> + <% } %> +
+
+ +<%- include('../partials/footer') %> diff --git a/webapp/src/views/partials/header.ejs b/webapp/src/views/partials/header.ejs index 23ae365..2859b95 100644 --- a/webapp/src/views/partials/header.ejs +++ b/webapp/src/views/partials/header.ejs @@ -43,6 +43,7 @@