-- ============================================ -- Migration: V20260120_003 -- Description: Migrate audit.audit_logs structure -- Changes: -- - Rename entity_type -> resource_type -- - Rename entity_id -> resource_id -- - Add user_email, actor_type columns -- - Add resource_name, severity columns -- - Add request_id, session_id columns -- - Add endpoint, http_method, response_status, duration_ms columns -- - Change changes JSONB -> old_values, new_values, changed_fields -- ============================================ -- UP Migration BEGIN; -- ============================================ -- 1. Ensure severity ENUM exists -- ============================================ DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_type WHERE typname = 'severity' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'audit') ) THEN CREATE TYPE audit.severity AS ENUM ('info', 'warning', 'error', 'critical'); RAISE NOTICE 'Created audit.severity enum'; END IF; END $$; -- ============================================ -- 2. Rename entity_type -> resource_type (if legacy) -- ============================================ DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'entity_type' ) THEN -- Drop old index if exists DROP INDEX IF EXISTS audit.idx_audit_logs_entity; -- Rename column ALTER TABLE audit.audit_logs RENAME COLUMN entity_type TO resource_type; -- Update column type if needed ALTER TABLE audit.audit_logs ALTER COLUMN resource_type TYPE VARCHAR(100); RAISE NOTICE 'Renamed entity_type to resource_type'; END IF; END $$; -- ============================================ -- 3. Rename entity_id -> resource_id (if legacy) -- ============================================ DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'entity_id' ) THEN ALTER TABLE audit.audit_logs RENAME COLUMN entity_id TO resource_id; ALTER TABLE audit.audit_logs ALTER COLUMN resource_id TYPE VARCHAR(255); RAISE NOTICE 'Renamed entity_id to resource_id'; END IF; END $$; -- ============================================ -- 4. Add actor columns -- ============================================ -- user_email DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'user_email' ) THEN ALTER TABLE audit.audit_logs ADD COLUMN user_email VARCHAR(255); -- Backfill user_email from users table UPDATE audit.audit_logs al SET user_email = u.email FROM users.users u WHERE al.user_id = u.id AND al.user_email IS NULL; RAISE NOTICE 'Added user_email column and backfilled from users table'; END IF; END $$; -- actor_type DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'actor_type' ) THEN ALTER TABLE audit.audit_logs ADD COLUMN actor_type VARCHAR(50) DEFAULT 'user'; RAISE NOTICE 'Added actor_type column'; END IF; END $$; -- ============================================ -- 5. Add resource_name column -- ============================================ DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'resource_name' ) THEN ALTER TABLE audit.audit_logs ADD COLUMN resource_name VARCHAR(255); RAISE NOTICE 'Added resource_name column'; END IF; END $$; -- ============================================ -- 6. Add severity column -- ============================================ DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'severity' ) THEN ALTER TABLE audit.audit_logs ADD COLUMN severity audit.severity DEFAULT 'info'; -- Create index for severity filtering CREATE INDEX idx_audit_logs_severity ON audit.audit_logs(tenant_id, severity) WHERE severity IN ('warning', 'error', 'critical'); RAISE NOTICE 'Added severity column'; END IF; END $$; -- ============================================ -- 7. Add context columns -- ============================================ -- request_id DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'request_id' ) THEN ALTER TABLE audit.audit_logs ADD COLUMN request_id VARCHAR(100); RAISE NOTICE 'Added request_id column'; END IF; END $$; -- session_id DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'session_id' ) THEN ALTER TABLE audit.audit_logs ADD COLUMN session_id UUID; RAISE NOTICE 'Added session_id column'; END IF; END $$; -- ============================================ -- 8. Add HTTP context columns -- ============================================ -- endpoint DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'endpoint' ) THEN ALTER TABLE audit.audit_logs ADD COLUMN endpoint VARCHAR(255); RAISE NOTICE 'Added endpoint column'; END IF; END $$; -- http_method DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'http_method' ) THEN ALTER TABLE audit.audit_logs ADD COLUMN http_method VARCHAR(10); RAISE NOTICE 'Added http_method column'; END IF; END $$; -- response_status DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'response_status' ) THEN ALTER TABLE audit.audit_logs ADD COLUMN response_status SMALLINT; RAISE NOTICE 'Added response_status column'; END IF; END $$; -- duration_ms DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'duration_ms' ) THEN ALTER TABLE audit.audit_logs ADD COLUMN duration_ms INTEGER; RAISE NOTICE 'Added duration_ms column'; END IF; END $$; -- ============================================ -- 9. Migrate changes JSONB -> old_values, new_values, changed_fields -- ============================================ -- old_values DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'old_values' ) THEN ALTER TABLE audit.audit_logs ADD COLUMN old_values JSONB; RAISE NOTICE 'Added old_values column'; END IF; END $$; -- new_values DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'new_values' ) THEN ALTER TABLE audit.audit_logs ADD COLUMN new_values JSONB; RAISE NOTICE 'Added new_values column'; END IF; END $$; -- changed_fields DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'changed_fields' ) THEN ALTER TABLE audit.audit_logs ADD COLUMN changed_fields JSONB; RAISE NOTICE 'Added changed_fields column'; END IF; END $$; -- Migrate data from 'changes' column if it exists DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'changes' ) THEN -- Extract old_values from changes UPDATE audit.audit_logs SET old_values = changes->'old' WHERE changes IS NOT NULL AND changes ? 'old' AND old_values IS NULL; -- Extract new_values from changes UPDATE audit.audit_logs SET new_values = changes->'new' WHERE changes IS NOT NULL AND changes ? 'new' AND new_values IS NULL; -- Extract changed_fields from changes UPDATE audit.audit_logs SET changed_fields = changes->'fields' WHERE changes IS NOT NULL AND changes ? 'fields' AND changed_fields IS NULL; -- Alternative: if changes is flat diff, create changed_fields from keys UPDATE audit.audit_logs SET changed_fields = ( SELECT jsonb_agg(key) FROM jsonb_object_keys(changes) AS key WHERE key NOT IN ('old', 'new', 'fields') ) WHERE changes IS NOT NULL AND NOT (changes ? 'old') AND NOT (changes ? 'new') AND changed_fields IS NULL; -- Drop old column after migration ALTER TABLE audit.audit_logs DROP COLUMN changes; RAISE NOTICE 'Migrated changes column to old_values, new_values, changed_fields'; END IF; END $$; -- ============================================ -- 10. Update/create indexes for new structure -- ============================================ -- Index for resource queries DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_indexes WHERE schemaname = 'audit' AND indexname = 'idx_audit_logs_resource' ) THEN CREATE INDEX idx_audit_logs_resource ON audit.audit_logs(tenant_id, resource_type, resource_id); END IF; END $$; -- ============================================ -- 11. Update audit.log_event function -- ============================================ CREATE OR REPLACE FUNCTION audit.log_event( p_tenant_id UUID, p_user_id UUID, p_action audit.action_type, p_resource_type VARCHAR, p_resource_id VARCHAR DEFAULT NULL, p_resource_name VARCHAR DEFAULT NULL, p_description TEXT DEFAULT NULL, p_old_values JSONB DEFAULT NULL, p_new_values JSONB DEFAULT NULL, p_changed_fields JSONB DEFAULT NULL, p_metadata JSONB DEFAULT '{}'::jsonb, p_severity audit.severity DEFAULT 'info' ) RETURNS UUID AS $$ DECLARE v_id UUID; v_user_email VARCHAR(255); BEGIN -- Get user email for denormalization SELECT email INTO v_user_email FROM users.users WHERE id = p_user_id; INSERT INTO audit.audit_logs ( tenant_id, user_id, user_email, action, resource_type, resource_id, resource_name, description, old_values, new_values, changed_fields, metadata, severity ) VALUES ( p_tenant_id, p_user_id, v_user_email, p_action, p_resource_type, p_resource_id, p_resource_name, p_description, p_old_values, p_new_values, p_changed_fields, p_metadata, p_severity ) RETURNING id INTO v_id; RETURN v_id; END; $$ LANGUAGE plpgsql; -- ============================================ -- 12. Update comments -- ============================================ COMMENT ON COLUMN audit.audit_logs.user_email IS 'Denormalized user email for fast queries'; COMMENT ON COLUMN audit.audit_logs.actor_type IS 'Type of actor: user, system, api_key, webhook'; COMMENT ON COLUMN audit.audit_logs.resource_type IS 'Type of resource: user, product, invoice, etc.'; COMMENT ON COLUMN audit.audit_logs.resource_id IS 'ID of the affected resource'; COMMENT ON COLUMN audit.audit_logs.resource_name IS 'Human-readable name of the resource'; COMMENT ON COLUMN audit.audit_logs.severity IS 'Log severity level: info, warning, error, critical'; COMMENT ON COLUMN audit.audit_logs.request_id IS 'Correlation ID for request tracing'; COMMENT ON COLUMN audit.audit_logs.session_id IS 'Session ID for session correlation'; COMMENT ON COLUMN audit.audit_logs.endpoint IS 'API endpoint path'; COMMENT ON COLUMN audit.audit_logs.http_method IS 'HTTP method: GET, POST, PUT, DELETE, etc.'; COMMENT ON COLUMN audit.audit_logs.response_status IS 'HTTP response status code'; COMMENT ON COLUMN audit.audit_logs.duration_ms IS 'Request duration in milliseconds'; COMMENT ON COLUMN audit.audit_logs.old_values IS 'Previous values before change'; COMMENT ON COLUMN audit.audit_logs.new_values IS 'New values after change'; COMMENT ON COLUMN audit.audit_logs.changed_fields IS 'List of fields that were changed'; COMMIT;