[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
This commit is contained in:
FreightDesk 2026-06-07 20:03:23 +00:00
parent 071f759b8a
commit 795cc86b5a
6 changed files with 322 additions and 0 deletions

View file

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

View file

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

View file

@ -204,6 +204,7 @@ app.use('/shippers', require('./routes/shippers'));
app.use('/vehicles', require('./routes/vehicles')); app.use('/vehicles', require('./routes/vehicles'));
app.use('/payments', require('./routes/payments')); app.use('/payments', require('./routes/payments'));
app.use('/reports', require('./routes/reports')); app.use('/reports', require('./routes/reports'));
app.use('/audit-logs', require('./routes/audit'));
// Health check // Health check
app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() })); app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));

View file

@ -0,0 +1,68 @@
<%- include('../partials/header', { activeMenu: 'audit' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128220; Audit Log Detail</h1>
<p class="page-subtitle"><%= log.id %></p>
</div>
<div class="page-actions">
<a href="/audit-logs" class="btn btn-outline">&larr; Back to Logs</a>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="detail-grid">
<div class="detail-item">
<label>Action</label>
<span class="badge badge-<%= log.action === 'INSERT' ? 'green' : log.action === 'UPDATE' ? 'blue' : log.action === 'SOFT_DELETE' ? 'orange' : 'red' %>"><%= log.action %></span>
</div>
<div class="detail-item">
<label>Table</label>
<code><%= log.table_name %></code>
</div>
<div class="detail-item">
<label>Row ID</label>
<code><%= log.row_id || '—' %></code>
</div>
<div class="detail-item">
<label>Timestamp</label>
<span><%= new Date(log.created_at).toLocaleString('en-IN') %></span>
</div>
<div class="detail-item">
<label>User ID</label>
<code><%= log.user_id || 'System' %></code>
</div>
<% if (log.notes) { %>
<div class="detail-item">
<label>Notes</label>
<span><%= log.notes %></span>
</div>
<% } %>
</div>
</div>
</div>
<% if (log.before_json) { %>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">&#128450; Before (Old Values)</h3>
</div>
<div class="card-body">
<pre class="code-block"><%= JSON.stringify(log.before_json, null, 2) %></pre>
</div>
</div>
<% } %>
<% if (log.after_json) { %>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">&#128451; After (New Values)</h3>
</div>
<div class="card-body">
<pre class="code-block"><%= JSON.stringify(log.after_json, null, 2) %></pre>
</div>
</div>
<% } %>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,93 @@
<%- include('../partials/header', { activeMenu: 'audit' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128220; Audit Logs</h1>
<p class="page-subtitle">Track all changes across the platform</p>
</div>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="/audit-logs" class="filter-bar">
<div class="form-group">
<label class="form-label">Table</label>
<select name="table" class="form-input" onchange="this.form.submit()">
<option value="">All Tables</option>
<option value="loads" <%= filters.table === 'loads' ? 'selected' : '' %>>Loads</option>
<option value="shippers" <%= filters.table === 'shippers' ? 'selected' : '' %>>Shippers</option>
<option value="vehicles" <%= filters.table === 'vehicles' ? 'selected' : '' %>>Vehicles</option>
<option value="payments" <%= filters.table === 'payments' ? 'selected' : '' %>>Payments</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Action</label>
<select name="action" class="form-input" onchange="this.form.submit()">
<option value="">All Actions</option>
<option value="INSERT" <%= filters.action === 'INSERT' ? 'selected' : '' %>>Insert</option>
<option value="UPDATE" <%= filters.action === 'UPDATE' ? 'selected' : '' %>>Update</option>
<option value="SOFT_DELETE" <%= filters.action === 'SOFT_DELETE' ? 'selected' : '' %>>Soft Delete</option>
<option value="HARD_DELETE" <%= filters.action === 'HARD_DELETE' ? 'selected' : '' %>>Hard Delete</option>
</select>
</div>
<div class="form-group">
<label class="form-label">&nbsp;</label>
<a href="/audit-logs" class="btn btn-outline">Clear</a>
</div>
</form>
</div>
</div>
<!-- Logs Table -->
<div class="card">
<div class="card-body">
<% if (!logs || logs.length === 0) { %>
<p class="empty-state">No audit logs found matching your filters.</p>
<% } else { %>
<p class="text-muted mb-3">Showing <%= logs.length %> of <%= total %> logs (page <%= page %> of <%= totalPages %>)</p>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>Table</th>
<th>Row ID</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<% for (const log of logs) { %>
<tr>
<td><%= new Date(log.created_at).toLocaleString('en-IN', { dateStyle: 'short', timeStyle: 'short' }) %></td>
<td>
<span class="badge badge-<%= log.action === 'INSERT' ? 'green' : log.action === 'UPDATE' ? 'blue' : log.action === 'SOFT_DELETE' ? 'orange' : 'red' %>">
<%= log.action %>
</span>
</td>
<td><code><%= log.table_name %></code></td>
<td><code><%= log.row_id ? log.row_id.slice(0,8) + '...' : '—' %></code></td>
<td><a href="/audit-logs/<%= log.id %>" class="btn btn-sm btn-outline">View</a></td>
</tr>
<% } %>
</tbody>
</table>
</div>
<!-- Pagination -->
<% if (totalPages > 1) { %>
<div class="pagination mt-3">
<% if (page > 1) { %>
<a href="/audit-logs?page=<%= page-1 %>&table=<%= filters.table || '' %>&action=<%= filters.action || '' %>" class="btn btn-sm btn-outline">&larr; Prev</a>
<% } %>
<span class="text-muted mx-2">Page <%= page %> of <%= totalPages %></span>
<% if (page < totalPages) { %>
<a href="/audit-logs?page=<%= page+1 %>&table=<%= filters.table || '' %>&action=<%= filters.action || '' %>" class="btn btn-sm btn-outline">Next &rarr;</a>
<% } %>
</div>
<% } %>
<% } %>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -43,6 +43,7 @@
<div class="sidebar-section"> <div class="sidebar-section">
<span class="sidebar-title">Reports</span> <span class="sidebar-title">Reports</span>
<a href="/reports" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'reports' ? 'active' : '' %>">&#128200; Reports</a> <a href="/reports" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'reports' ? 'active' : '' %>">&#128200; Reports</a>
<a href="/audit-logs" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'audit' ? 'active' : '' %>">&#128220; Audit Logs</a>
</div> </div>
</aside> </aside>