From f1ece4b1828fb9e0902df0693ec7a684deeed0b1 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 7 Jun 2026 19:49:51 +0000 Subject: [PATCH] feat[agent]: add soft delete and audit logging for core tables --- supabase/migrations/003_soft_delete.sql | 150 ++++++++++++++++++++---- 1 file changed, 130 insertions(+), 20 deletions(-) diff --git a/supabase/migrations/003_soft_delete.sql b/supabase/migrations/003_soft_delete.sql index 80ca5ea..c4b3696 100644 --- a/supabase/migrations/003_soft_delete.sql +++ b/supabase/migrations/003_soft_delete.sql @@ -1,24 +1,134 @@ -- ============================================================ --- FreightDesk — Migration 003: Soft Delete + Security +-- SOFT DELETE & AUDIT LOG EXTENSION -- ============================================================ +-- Add soft delete columns to core tables +-- ============================================================ +ALTER TABLE loads ADD COLUMN deleted_at TIMESTAMPTZ; +ALTER TABLE shippers ADD COLUMN deleted_at TIMESTAMPTZ; +ALTER TABLE vehicles ADD COLUMN deleted_at TIMESTAMPTZ; +ALTER TABLE payments ADD COLUMN deleted_at TIMESTAMPTZ; +ALTER TABLE portal_users ADD COLUMN deleted_at TIMESTAMPTZ; --- Add soft-delete columns -ALTER TABLE loads ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; -ALTER TABLE payments ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; -ALTER TABLE shippers ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; -ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; - --- Add role column to portal_users if not exists -ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'admin'; - --- Add index for soft-delete queries -CREATE INDEX IF NOT EXISTS idx_loads_deleted_at ON loads(deleted_at) WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS idx_payments_deleted_at ON payments(deleted_at) WHERE deleted_at IS NULL; - --- Add load_count to shippers for quick reference -ALTER TABLE shippers ADD COLUMN IF NOT EXISTS load_count INTEGER DEFAULT 0; - --- Update load_count for existing shippers -UPDATE shippers SET load_count = ( - SELECT COUNT(*) FROM loads WHERE loads.shipper_id = shippers.id +-- ============================================================ +-- AUDIT LOG TABLE +-- ============================================================ +CREATE TABLE 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 (if applicable) + before_json JSONB, -- state before change + after_json JSONB, -- state after change + created_at TIMESTAMPTZ DEFAULT NOW(), + user_id UUID, -- references admin who made change (nullable) + notes TEXT ); + +-- Indexes for fast lookup +CREATE INDEX idx_audit_logs_table ON audit_logs(table_name); +CREATE INDEX idx_audit_logs_user ON audit_logs(user_id); +CREATE INDEX idx_audit_logs_time ON audit_logs(created_at DESC); + +-- ============================================================ +-- TRIGGERS FOR SOFT DELETE & AUDIT LOGGING +-- ============================================================ + +-- Function to perform soft delete and log action +CREATE OR REPLACE FUNCTION soft_delete_and_audit() +RETURNS TRIGGER AS $$ +DECLARE + v_user_id UUID; +BEGIN + -- Determine which table triggered the trigger + IF TG_TABLE_NAME = 'loads' THEN + IF OLD.deleted_at IS NOT NULL THEN + RETURN OLD; -- already soft-deleted + END IF; + UPDATE loads SET deleted_at = NOW() WHERE id = OLD.id; + IF TG_OP = 'DELETE' THEN + INSERT INTO audit_logs(action, table_name, row_id, before_json, created_at, user_id) + VALUES ('DELETE', TG_TABLE_NAME, OLD.id::UUID, to_jsonb(OLD), NOW(), current_setting('user_id')::UUID); + ELSIF TG_OP = 'UPDATE' THEN + INSERT INTO audit_logs(action, table_name, row_id, before_json, after_json, created_at, user_id) + VALUES ('UPDATE', TG_TABLE_NAME, NEW.id::UUID, to_jsonb(OLD), to_jsonb(NEW), NOW(), current_setting('user_id')::UUID); + ELSIF TG_OP = 'INSERT' THEN + INSERT INTO audit_logs(action, table_name, row_id, after_json, created_at, user_id) + VALUES ('INSERT', TG_TABLE_NAME, NEW.id::UUID, NULL, NOW(), current_setting('user_id')::UUID); + END IF; + ELSIF TG_TABLE_NAME = 'shippers' THEN + IF OLD.deleted_at IS NOT NULL THEN + RETURN OLD; + END IF; + UPDATE shippers SET deleted_at = NOW() WHERE id = OLD.id; + IF TG_OP = 'DELETE' THEN + INSERT INTO audit_logs(action, table_name, row_id, before_json, created_at, user_id) + VALUES ('DELETE', TG_TABLE_NAME, OLD.id::UUID, to_jsonb(OLD), NOW(), current_setting('user_id')::UUID); + ELSIF TG_OP = 'UPDATE' THEN + INSERT INTO audit_logs(action, table_name, row_id, before_json, after_json, created_at, user_id) + VALUES ('UPDATE', TG_TABLE_NAME, NEW.id::UUID, to_jsonb(OLD), to_jsonb(NEW), NOW(), current_setting('user_id')::UUID); + ELSIF TG_OP = 'INSERT' THEN + INSERT INTO audit_logs(action, table_name, row_id, after_json, created_at, user_id) + VALUES ('INSERT', TG_TABLE_NAME, NEW.id::UUID, to_jsonb(NEW), NOW(), current_setting('user_id')::UUID); + END IF; + ELSIF TG_TABLE_NAME = 'vehicles' THEN + IF OLD.deleted_at IS NOT NULL THEN + RETURN OLD; + END IF; + UPDATE vehicles SET deleted_at = NOW() WHERE id = OLD.id; + IF TG_OP = 'DELETE' THEN + INSERT INTO audit_logs(action, table_name, row_id, before_json, created_at, user_id) + VALUES ('DELETE', TG_TABLE_NAME, OLD.id::UUID, to_jsonb(OLD), NOW(), current_setting('user_id')::UUID); + ELSIF TG_OP = 'UPDATE' THEN + INSERT INTO audit_logs(action, table_name, row_id, before_json, after_json, created_at, user_id) + VALUES ('UPDATE', TG_TABLE_NAME, NEW.id::UUID, to_jsonb(OLD), to_jsonb(NEW), NOW(), current_setting('user_id')::UUID); + ELSIF TG_OP = 'INSERT' THEN + INSERT INTO audit_logs(action, table_name, row_id, after_json, created_at, user_id) + VALUES ('INSERT', TG_TABLE_NAME, NEW.id::UUID, to_jsonb(NEW), NOW(), current_setting('user_id')::UUID); + END IF; + ELSIF TG_TABLE_NAME = 'payments' THEN + IF OLD.deleted_at IS NOT NULL THEN + RETURN OLD; + END IF; + UPDATE payments SET deleted_at = NOW() WHERE id = OLD.id; + IF TG_OP = 'DELETE' THEN + INSERT INTO audit_logs(action, table_name, row_id, before_json, created_at, user_id) + VALUES ('DELETE', TG_TABLE_NAME, OLD.id::UUID, to_jsonb(OLD), NOW(), current_setting('user_id')::UUID); + ELSIF TG_OP = 'UPDATE' THEN + INSERT INTO audit_logs(action, table_name, row_id, before_json, after_json, created_at, user_id) + VALUES ('UPDATE', TG_TABLE_NAME, NEW.id::UUID, to_jsonb(OLD), to_jsonb(NEW), NOW(), current_setting('user_id')::UUID); + ELSIF TG_OP = 'INSERT' THEN + INSERT INTO audit_logs(action, table_name, row_id, after_json, created_at, user_id) + VALUES ('INSERT', TG_TABLE_NAME, NEW.id::UUID, to_jsonb(NEW), NOW(), current_setting('user_id')::UUID); + END IF; + END IF; + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +-- Create triggers for soft delete and audit logging on each table +CREATE TRIGGER trg_loads_soft_delete BEFORE UPDATE OR DELETE ON loads + FOR EACH ROW EXECUTE FUNCTION soft_delete_and_audit(); +CREATE TRIGGER trg_loads_soft_delete_INSERT BEFORE INSERT ON loads + FOR EACH ROW EXECUTE FUNCTION soft_delete_and_audit(); + +CREATE TRIGGER trg_shippers_soft_delete BEFORE UPDATE OR DELETE ON shippers + FOR EACH ROW EXECUTE FUNCTION soft_delete_and_audit(); +CREATE TRIGGER trg_shippers_soft_delete_INSERT BEFORE INSERT ON shippers + FOR EACH ROW EXECUTE FUNCTION soft_delete_and_audit(); + +CREATE TRIGGER trg_vehicles_soft_delete BEFORE UPDATE OR DELETE ON vehicles + FOR EACH ROW EXECUTE FUNCTION soft_delete_and_audit(); +CREATE TRIGGER trg_vehicles_soft_delete_INSERT BEFORE INSERT ON vehicles + FOR EACH ROW EXECUTE FUNCTION soft_delete_and_audit(); + +CREATE TRIGGER trg_payments_soft_delete BEFORE UPDATE OR DELETE ON payments + FOR EACH ROW EXECUTE FUNCTION soft_delete_and_audit(); +CREATE TRIGGER trg_payments_soft_delete_INSERT BEFORE INSERT ON payments + FOR EACH ROW EXECUTE FUNCTION soft_delete_and_audit(); + +-- ============================================================ +-- AUDIT SETUP FUNCTION TO CAPTURE USER ID FROM SESSION CONTEXT +-- ============================================================ +-- Note: PostgreSQL cannot directly access Express session, so we pass user_id +-- via SET user_id = '' before triggering audits. +-- Application must set this variable before performing DB operations.