-- ============================================================================ -- 13-audit-triggers-ddl.sql -- Schema: audit -- ERP Construccion - GAP-007: Audit Triggers -- ============================================================================ -- Descripcion: Sistema de auditoría detallada con: -- - Registro de cambios a nivel de campo -- - Triggers automáticos para tablas críticas -- - Historial completo de modificaciones -- ============================================================================ -- Autor: Claude-Arquitecto-Orquestador -- Fecha: 2026-02-04 -- Version: 1.0.0 -- GAP: GAP-007 -- ============================================================================ -- Crear schema si no existe CREATE SCHEMA IF NOT EXISTS audit; -- ============================================================================ -- ENUMS -- ============================================================================ -- Tipo de operación CREATE TYPE audit.operation_type AS ENUM ( 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE' ); -- ============================================================================ -- TABLA DE AUDIT LOG -- ============================================================================ CREATE TABLE IF NOT EXISTS audit.change_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Contexto tenant_id UUID, user_id UUID, session_id VARCHAR(100), -- Operación operation audit.operation_type NOT NULL, table_schema VARCHAR(63) NOT NULL, table_name VARCHAR(63) NOT NULL, record_id UUID, -- Datos old_data JSONB, new_data JSONB, changed_fields TEXT[], -- Metadatos client_ip INET, user_agent TEXT, application_name VARCHAR(100), -- Timestamp executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Para queries eficientes (calculado, no generado - por compatibilidad) executed_date DATE ); -- Índices para change_log CREATE INDEX IF NOT EXISTS idx_change_log_tenant ON audit.change_log(tenant_id); CREATE INDEX IF NOT EXISTS idx_change_log_user ON audit.change_log(user_id); CREATE INDEX IF NOT EXISTS idx_change_log_table ON audit.change_log(table_schema, table_name); CREATE INDEX IF NOT EXISTS idx_change_log_record ON audit.change_log(record_id); CREATE INDEX IF NOT EXISTS idx_change_log_executed ON audit.change_log(executed_at DESC); CREATE INDEX IF NOT EXISTS idx_change_log_date ON audit.change_log(executed_date); CREATE INDEX IF NOT EXISTS idx_change_log_operation ON audit.change_log(operation); -- Particionamiento por fecha (opcional, para grandes volúmenes) -- La tabla puede ser particionada posteriormente si es necesario -- ============================================================================ -- FUNCIÓN DE TRIGGER GENÉRICA -- ============================================================================ CREATE OR REPLACE FUNCTION audit.log_changes() RETURNS TRIGGER AS $$ DECLARE v_tenant_id UUID; v_user_id UUID; v_session_id TEXT; v_old_data JSONB; v_new_data JSONB; v_changed_fields TEXT[]; v_record_id UUID; v_key TEXT; BEGIN -- Obtener contexto de la sesión BEGIN v_tenant_id := current_setting('app.current_tenant_id', true)::uuid; EXCEPTION WHEN OTHERS THEN v_tenant_id := NULL; END; BEGIN v_user_id := current_setting('app.current_user_id', true)::uuid; EXCEPTION WHEN OTHERS THEN v_user_id := NULL; END; BEGIN v_session_id := current_setting('app.session_id', true); EXCEPTION WHEN OTHERS THEN v_session_id := NULL; END; -- Determinar datos según operación IF TG_OP = 'INSERT' THEN v_new_data := to_jsonb(NEW); v_old_data := NULL; v_record_id := NEW.id; v_changed_fields := ARRAY(SELECT jsonb_object_keys(v_new_data)); ELSIF TG_OP = 'UPDATE' THEN v_old_data := to_jsonb(OLD); v_new_data := to_jsonb(NEW); v_record_id := NEW.id; -- Calcular campos modificados v_changed_fields := ARRAY( SELECT key FROM ( SELECT key, v_old_data->key AS old_val, v_new_data->key AS new_val FROM jsonb_object_keys(v_new_data) AS key ) AS changes WHERE old_val IS DISTINCT FROM new_val ); -- Si no hay cambios reales, no registrar IF array_length(v_changed_fields, 1) IS NULL THEN RETURN NEW; END IF; ELSIF TG_OP = 'DELETE' THEN v_old_data := to_jsonb(OLD); v_new_data := NULL; v_record_id := OLD.id; v_changed_fields := NULL; END IF; -- Insertar registro de auditoría INSERT INTO audit.change_log ( tenant_id, user_id, session_id, operation, table_schema, table_name, record_id, old_data, new_data, changed_fields, client_ip, application_name ) VALUES ( v_tenant_id, v_user_id, v_session_id, TG_OP::audit.operation_type, TG_TABLE_SCHEMA, TG_TABLE_NAME, v_record_id, v_old_data, v_new_data, v_changed_fields, inet_client_addr(), current_setting('application_name', true) ); -- Retornar el registro apropiado IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF; END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- ============================================================================ -- FUNCIÓN PARA CREAR TRIGGER DE AUDITORÍA -- ============================================================================ CREATE OR REPLACE FUNCTION audit.enable_audit_trigger( p_schema_name TEXT, p_table_name TEXT ) RETURNS VOID AS $$ DECLARE v_trigger_name TEXT; BEGIN v_trigger_name := 'audit_' || p_table_name || '_changes'; -- Eliminar trigger si existe EXECUTE format( 'DROP TRIGGER IF EXISTS %I ON %I.%I', v_trigger_name, p_schema_name, p_table_name ); -- Crear trigger EXECUTE format( 'CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON %I.%I FOR EACH ROW EXECUTE FUNCTION audit.log_changes()', v_trigger_name, p_schema_name, p_table_name ); RAISE NOTICE 'Audit trigger creado: %.%', p_schema_name, p_table_name; END; $$ LANGUAGE plpgsql; -- ============================================================================ -- FUNCIÓN PARA DESACTIVAR TRIGGER DE AUDITORÍA -- ============================================================================ CREATE OR REPLACE FUNCTION audit.disable_audit_trigger( p_schema_name TEXT, p_table_name TEXT ) RETURNS VOID AS $$ DECLARE v_trigger_name TEXT; BEGIN v_trigger_name := 'audit_' || p_table_name || '_changes'; EXECUTE format( 'DROP TRIGGER IF EXISTS %I ON %I.%I', v_trigger_name, p_schema_name, p_table_name ); RAISE NOTICE 'Audit trigger eliminado: %.%', p_schema_name, p_table_name; END; $$ LANGUAGE plpgsql; -- ============================================================================ -- ACTIVAR AUDITORÍA EN TABLAS CRÍTICAS -- ============================================================================ -- Construction: Proyectos y Fraccionamientos SELECT audit.enable_audit_trigger('construction', 'fraccionamientos'); SELECT audit.enable_audit_trigger('construction', 'proyectos'); -- Estimates: Estimaciones (facturación) SELECT audit.enable_audit_trigger('estimates', 'estimaciones'); SELECT audit.enable_audit_trigger('estimates', 'anticipos'); -- Finance: Transacciones financieras SELECT audit.enable_audit_trigger('finance', 'journal_entries'); SELECT audit.enable_audit_trigger('finance', 'invoices'); SELECT audit.enable_audit_trigger('finance', 'payments'); -- Assets: Activos fijos SELECT audit.enable_audit_trigger('assets', 'assets'); SELECT audit.enable_audit_trigger('assets', 'depreciation_schedule'); -- Documents: Documentos y firmas SELECT audit.enable_audit_trigger('documents', 'documents'); SELECT audit.enable_audit_trigger('documents', 'digital_signatures'); -- HR: Empleados SELECT audit.enable_audit_trigger('hr', 'employees'); -- HSE: Incidentes de seguridad SELECT audit.enable_audit_trigger('hse', 'incidentes'); -- ============================================================================ -- FUNCIONES DE CONSULTA -- ============================================================================ -- Obtener historial de cambios de un registro CREATE OR REPLACE FUNCTION audit.get_record_history( p_table_schema TEXT, p_table_name TEXT, p_record_id UUID, p_limit INT DEFAULT 100 ) RETURNS TABLE ( change_id UUID, operation audit.operation_type, changed_fields TEXT[], old_data JSONB, new_data JSONB, user_id UUID, executed_at TIMESTAMPTZ ) AS $$ BEGIN RETURN QUERY SELECT cl.id, cl.operation, cl.changed_fields, cl.old_data, cl.new_data, cl.user_id, cl.executed_at FROM audit.change_log cl WHERE cl.table_schema = p_table_schema AND cl.table_name = p_table_name AND cl.record_id = p_record_id ORDER BY cl.executed_at DESC LIMIT p_limit; END; $$ LANGUAGE plpgsql; -- Obtener cambios recientes por tenant CREATE OR REPLACE FUNCTION audit.get_recent_changes( p_tenant_id UUID, p_hours INT DEFAULT 24, p_limit INT DEFAULT 100 ) RETURNS TABLE ( change_id UUID, table_schema VARCHAR, table_name VARCHAR, record_id UUID, operation audit.operation_type, user_id UUID, executed_at TIMESTAMPTZ ) AS $$ BEGIN RETURN QUERY SELECT cl.id, cl.table_schema, cl.table_name, cl.record_id, cl.operation, cl.user_id, cl.executed_at FROM audit.change_log cl WHERE cl.tenant_id = p_tenant_id AND cl.executed_at >= NOW() - (p_hours || ' hours')::interval ORDER BY cl.executed_at DESC LIMIT p_limit; END; $$ LANGUAGE plpgsql; -- ============================================================================ -- COMENTARIOS -- ============================================================================ COMMENT ON SCHEMA audit IS 'GAP-007: Sistema de auditoría detallada'; COMMENT ON TABLE audit.change_log IS 'Registro de todos los cambios en tablas auditadas'; COMMENT ON FUNCTION audit.log_changes() IS 'Trigger function para registrar cambios'; COMMENT ON FUNCTION audit.enable_audit_trigger(TEXT, TEXT) IS 'Habilitar auditoría en una tabla'; COMMENT ON FUNCTION audit.disable_audit_trigger(TEXT, TEXT) IS 'Deshabilitar auditoría en una tabla'; -- ============================================================================ -- FIN DEL SCRIPT -- Tablas auditadas: 13 tablas críticas -- ============================================================================