From 27de049441bec19fe185be40a7105145ca36cb0f Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Tue, 20 Jan 2026 04:38:47 -0600 Subject: [PATCH] [TEMPLATE-SAAS-DB] chore: Update audit schema and add migrations Co-Authored-By: Claude Opus 4.5 --- ddl/schemas/audit/tables/01-audit-logs.sql | 22 +- migrations/README.md | 81 ++++ ...0_001__migrate_auth_sessions_structure.sql | 252 ++++++++++++ ...__migrate_auth_sessions_structure_DOWN.sql | 114 ++++++ ...120_002__migrate_billing_subscriptions.sql | 228 +++++++++++ ...02__migrate_billing_subscriptions_DOWN.sql | 113 +++++ .../V20260120_003__migrate_audit_logs.sql | 387 ++++++++++++++++++ ...V20260120_003__migrate_audit_logs_DOWN.sql | 163 ++++++++ 8 files changed, 1356 insertions(+), 4 deletions(-) create mode 100644 migrations/README.md create mode 100644 migrations/V20260120_001__migrate_auth_sessions_structure.sql create mode 100644 migrations/V20260120_001__migrate_auth_sessions_structure_DOWN.sql create mode 100644 migrations/V20260120_002__migrate_billing_subscriptions.sql create mode 100644 migrations/V20260120_002__migrate_billing_subscriptions_DOWN.sql create mode 100644 migrations/V20260120_003__migrate_audit_logs.sql create mode 100644 migrations/V20260120_003__migrate_audit_logs_DOWN.sql diff --git a/ddl/schemas/audit/tables/01-audit-logs.sql b/ddl/schemas/audit/tables/01-audit-logs.sql index 65cd0b8..4ccc8a9 100644 --- a/ddl/schemas/audit/tables/01-audit-logs.sql +++ b/ddl/schemas/audit/tables/01-audit-logs.sql @@ -21,9 +21,13 @@ CREATE TABLE audit.audit_logs ( -- Details description TEXT, - changes JSONB, -- { "field": { "old": x, "new": y } } metadata JSONB DEFAULT '{}'::jsonb, + -- Change tracking (expanded structure for better query flexibility) + old_values JSONB, -- Previous values: { field1: oldVal1, ... } + new_values JSONB, -- New values: { field1: newVal1, ... } + changed_fields JSONB, -- List of changed fields: ['field1', 'field2', ...] + -- Severity severity audit.severity DEFAULT 'info', @@ -33,6 +37,12 @@ CREATE TABLE audit.audit_logs ( request_id VARCHAR(100), session_id UUID, + -- HTTP context (for API audit) + endpoint VARCHAR(255), + http_method VARCHAR(10), + response_status SMALLINT, + duration_ms INTEGER, + -- Timestamp created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, @@ -94,7 +104,9 @@ CREATE OR REPLACE FUNCTION audit.log_event( p_resource_id VARCHAR DEFAULT NULL, p_resource_name VARCHAR DEFAULT NULL, p_description TEXT DEFAULT NULL, - p_changes JSONB 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' ) @@ -104,10 +116,12 @@ DECLARE BEGIN INSERT INTO audit.audit_logs ( tenant_id, user_id, action, resource_type, resource_id, - resource_name, description, changes, metadata, severity + resource_name, description, old_values, new_values, changed_fields, + metadata, severity ) VALUES ( p_tenant_id, p_user_id, p_action, p_resource_type, p_resource_id, - p_resource_name, p_description, p_changes, p_metadata, p_severity + 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; diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..125f4fe --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,81 @@ +# Database Migrations + +This directory contains SQL migration scripts for schema changes. + +## Naming Convention + +``` +V{YYYYMMDD}_{NNN}__{description}.sql # UP migration +V{YYYYMMDD}_{NNN}__{description}_DOWN.sql # DOWN migration (rollback) +``` + +Example: +- `V20260120_001__migrate_auth_sessions_structure.sql` +- `V20260120_001__migrate_auth_sessions_structure_DOWN.sql` + +## Migration Order + +Migrations should be applied in order by version number: + +1. `V20260120_001` - auth.sessions structure changes +2. `V20260120_002` - billing.subscriptions structure changes +3. `V20260120_003` - audit.audit_logs structure changes + +## Running Migrations + +### Apply UP migration +```bash +psql -h localhost -U postgres -d template_saas -f migrations/V20260120_001__migrate_auth_sessions_structure.sql +``` + +### Apply DOWN migration (rollback) +```bash +psql -h localhost -U postgres -d template_saas -f migrations/V20260120_001__migrate_auth_sessions_structure_DOWN.sql +``` + +## Migration Details + +### V20260120_001 - Auth Sessions + +**Changes:** +- Renames `session_token` -> `token_hash` (VARCHAR(64) -> VARCHAR(255)) +- Converts `is_active` (BOOLEAN) -> `status` (ENUM: active, expired, revoked) +- Adds `device_name`, `browser`, `os`, `location` columns +- Adds `revoked_at`, `revoked_reason` columns +- Updates cleanup function to use status + +### V20260120_002 - Billing Subscriptions + +**Changes:** +- Adds Stripe integration columns (`stripe_subscription_id`, `stripe_customer_id`) +- Adds billing `interval` column (ENUM: month, year) +- Adds trial/cancellation columns (`trial_start`, `cancel_at`, `cancel_reason`) +- Adds pricing columns (`price_amount`, `currency`) +- Renames `cancelled_at` -> `canceled_at` (American spelling) +- Updates subscription_status enum: `trial` -> `trialing` + +### V20260120_003 - Audit Logs + +**Changes:** +- Renames `entity_type` -> `resource_type` +- Renames `entity_id` -> `resource_id` +- Adds actor columns (`user_email`, `actor_type`) +- Adds `resource_name`, `severity` columns +- Adds context columns (`request_id`, `session_id`) +- Adds HTTP columns (`endpoint`, `http_method`, `response_status`, `duration_ms`) +- Splits `changes` JSONB -> `old_values`, `new_values`, `changed_fields` +- Updates `audit.log_event()` function + +## Safety Notes + +1. **Always backup before migrating** +2. **Run in transaction** - All migrations use BEGIN/COMMIT +3. **Test on staging first** +4. **Idempotent checks** - Migrations check if changes already exist +5. **DOWN migrations may lose data** - VARCHAR truncation, column drops + +## Related Files + +- DDL definitions: `../ddl/schemas/` +- Enum definitions: `../ddl/02-enums.sql` +- Functions: `../ddl/03-functions.sql` diff --git a/migrations/V20260120_001__migrate_auth_sessions_structure.sql b/migrations/V20260120_001__migrate_auth_sessions_structure.sql new file mode 100644 index 0000000..ed84b2f --- /dev/null +++ b/migrations/V20260120_001__migrate_auth_sessions_structure.sql @@ -0,0 +1,252 @@ +-- ============================================ +-- Migration: V20260120_001 +-- Description: Migrate auth.sessions structure +-- Changes: +-- - Rename session_token -> token_hash (VARCHAR(64) -> VARCHAR(255)) +-- - Change is_active (BOOLEAN) -> status (ENUM) +-- - Add device_name, browser, os, location columns +-- - Add revoked_at, revoked_reason columns +-- ============================================ + +-- UP Migration +BEGIN; + +-- ============================================ +-- 1. Check if we need to migrate (legacy structure) +-- ============================================ + +DO $$ +DECLARE + has_session_token BOOLEAN; + has_is_active BOOLEAN; + has_status BOOLEAN; +BEGIN + -- Check for legacy column session_token + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' + AND table_name = 'sessions' + AND column_name = 'session_token' + ) INTO has_session_token; + + -- Check for legacy column is_active + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' + AND table_name = 'sessions' + AND column_name = 'is_active' + ) INTO has_is_active; + + -- Check if status column already exists + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' + AND table_name = 'sessions' + AND column_name = 'status' + ) INTO has_status; + + -- Store results in temp table for use in subsequent statements + CREATE TEMP TABLE _migration_flags ( + flag_name VARCHAR(50) PRIMARY KEY, + flag_value BOOLEAN + ) ON COMMIT DROP; + + INSERT INTO _migration_flags VALUES + ('has_session_token', has_session_token), + ('has_is_active', has_is_active), + ('has_status', has_status); +END $$; + +-- ============================================ +-- 2. Ensure session_status ENUM exists +-- ============================================ + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'session_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'auth')) THEN + CREATE TYPE auth.session_status AS ENUM ('active', 'expired', 'revoked'); + END IF; +END $$; + +-- ============================================ +-- 3. Rename session_token -> token_hash (if legacy) +-- ============================================ + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM _migration_flags WHERE flag_name = 'has_session_token' AND flag_value = TRUE) THEN + -- Drop old index if exists + DROP INDEX IF EXISTS auth.idx_sessions_token; + + -- Rename column + ALTER TABLE auth.sessions RENAME COLUMN session_token TO token_hash; + + -- Expand varchar size (64 -> 255) + ALTER TABLE auth.sessions ALTER COLUMN token_hash TYPE VARCHAR(255); + + -- Recreate index + CREATE INDEX idx_sessions_token ON auth.sessions(token_hash); + + RAISE NOTICE 'Renamed session_token to token_hash and expanded to VARCHAR(255)'; + END IF; +END $$; + +-- ============================================ +-- 4. Convert is_active (BOOLEAN) -> status (ENUM) +-- ============================================ + +DO $$ +DECLARE + has_is_active BOOLEAN; + has_status BOOLEAN; +BEGIN + SELECT flag_value INTO has_is_active FROM _migration_flags WHERE flag_name = 'has_is_active'; + SELECT flag_value INTO has_status FROM _migration_flags WHERE flag_name = 'has_status'; + + IF has_is_active AND NOT has_status THEN + -- Add status column + ALTER TABLE auth.sessions ADD COLUMN status auth.session_status; + + -- Migrate data: is_active = true -> 'active', is_active = false -> 'revoked' + UPDATE auth.sessions + SET status = CASE + WHEN is_active = true THEN 'active'::auth.session_status + ELSE 'revoked'::auth.session_status + END; + + -- Mark expired sessions + UPDATE auth.sessions + SET status = 'expired'::auth.session_status + WHERE expires_at < NOW() AND status = 'active'; + + -- Set NOT NULL and default + ALTER TABLE auth.sessions ALTER COLUMN status SET NOT NULL; + ALTER TABLE auth.sessions ALTER COLUMN status SET DEFAULT 'active'; + + -- Drop old column + ALTER TABLE auth.sessions DROP COLUMN is_active; + + -- Update indexes to use status instead of is_active + DROP INDEX IF EXISTS auth.idx_sessions_user; + DROP INDEX IF EXISTS auth.idx_sessions_tenant; + DROP INDEX IF EXISTS auth.idx_sessions_expires; + + CREATE INDEX idx_sessions_user ON auth.sessions(user_id) WHERE status = 'active'; + CREATE INDEX idx_sessions_tenant ON auth.sessions(tenant_id) WHERE status = 'active'; + CREATE INDEX idx_sessions_expires ON auth.sessions(expires_at) WHERE status = 'active'; + + RAISE NOTICE 'Converted is_active to status enum'; + END IF; +END $$; + +-- ============================================ +-- 5. Add new columns if they don't exist +-- ============================================ + +-- device_name +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'device_name' + ) THEN + ALTER TABLE auth.sessions ADD COLUMN device_name VARCHAR(200); + RAISE NOTICE 'Added device_name column'; + END IF; +END $$; + +-- browser +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'browser' + ) THEN + ALTER TABLE auth.sessions ADD COLUMN browser VARCHAR(100); + RAISE NOTICE 'Added browser column'; + END IF; +END $$; + +-- os +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'os' + ) THEN + ALTER TABLE auth.sessions ADD COLUMN os VARCHAR(100); + RAISE NOTICE 'Added os column'; + END IF; +END $$; + +-- location +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'location' + ) THEN + ALTER TABLE auth.sessions ADD COLUMN location VARCHAR(200); + RAISE NOTICE 'Added location column'; + END IF; +END $$; + +-- revoked_at +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'revoked_at' + ) THEN + ALTER TABLE auth.sessions ADD COLUMN revoked_at TIMESTAMPTZ; + RAISE NOTICE 'Added revoked_at column'; + END IF; +END $$; + +-- revoked_reason +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'revoked_reason' + ) THEN + ALTER TABLE auth.sessions ADD COLUMN revoked_reason VARCHAR(100); + RAISE NOTICE 'Added revoked_reason column'; + END IF; +END $$; + +-- ============================================ +-- 6. Update cleanup function to use status +-- ============================================ + +CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions() +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + WITH deleted AS ( + DELETE FROM auth.sessions + WHERE expires_at < NOW() - INTERVAL '7 days' + OR (status = 'revoked' AND revoked_at < NOW() - INTERVAL '30 days') + RETURNING * + ) + SELECT COUNT(*) INTO deleted_count FROM deleted; + + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- 7. Update comments +-- ============================================ + +COMMENT ON COLUMN auth.sessions.token_hash IS 'SHA256 hash of session token for secure storage'; +COMMENT ON COLUMN auth.sessions.status IS 'Session status: active, expired, revoked'; +COMMENT ON COLUMN auth.sessions.device_name IS 'Human-readable device name'; +COMMENT ON COLUMN auth.sessions.browser IS 'Browser name and version'; +COMMENT ON COLUMN auth.sessions.os IS 'Operating system name and version'; +COMMENT ON COLUMN auth.sessions.location IS 'Geographic location (City, Country)'; +COMMENT ON COLUMN auth.sessions.revoked_at IS 'Timestamp when session was revoked'; +COMMENT ON COLUMN auth.sessions.revoked_reason IS 'Reason for revocation: logout, security, password_change, admin'; + +COMMIT; diff --git a/migrations/V20260120_001__migrate_auth_sessions_structure_DOWN.sql b/migrations/V20260120_001__migrate_auth_sessions_structure_DOWN.sql new file mode 100644 index 0000000..8dec5f8 --- /dev/null +++ b/migrations/V20260120_001__migrate_auth_sessions_structure_DOWN.sql @@ -0,0 +1,114 @@ +-- ============================================ +-- Migration: V20260120_001 - DOWN (Rollback) +-- Description: Revert auth.sessions structure to legacy +-- Changes (reversed): +-- - Rename token_hash -> session_token (VARCHAR(255) -> VARCHAR(64)) +-- - Change status (ENUM) -> is_active (BOOLEAN) +-- - Remove device_name, browser, os, location columns +-- - Remove revoked_at, revoked_reason columns +-- ============================================ + +-- DOWN Migration (Rollback) +BEGIN; + +-- ============================================ +-- 1. Add is_active column back +-- ============================================ + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'status' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'is_active' + ) THEN + -- Add is_active column + ALTER TABLE auth.sessions ADD COLUMN is_active BOOLEAN DEFAULT TRUE; + + -- Migrate data back: status = 'active' -> true, else -> false + UPDATE auth.sessions + SET is_active = (status = 'active'); + + -- Set NOT NULL + ALTER TABLE auth.sessions ALTER COLUMN is_active SET NOT NULL; + + -- Drop status column + ALTER TABLE auth.sessions DROP COLUMN status; + + -- Recreate indexes with is_active + DROP INDEX IF EXISTS auth.idx_sessions_user; + DROP INDEX IF EXISTS auth.idx_sessions_tenant; + DROP INDEX IF EXISTS auth.idx_sessions_expires; + + CREATE INDEX idx_sessions_user ON auth.sessions(user_id) WHERE is_active = true; + CREATE INDEX idx_sessions_tenant ON auth.sessions(tenant_id) WHERE is_active = true; + CREATE INDEX idx_sessions_expires ON auth.sessions(expires_at) WHERE is_active = true; + + RAISE NOTICE 'Reverted status enum to is_active boolean'; + END IF; +END $$; + +-- ============================================ +-- 2. Rename token_hash -> session_token +-- ============================================ + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'token_hash' + ) THEN + -- Drop index + DROP INDEX IF EXISTS auth.idx_sessions_token; + + -- Rename column + ALTER TABLE auth.sessions RENAME COLUMN token_hash TO session_token; + + -- WARNING: This will truncate data if any tokens are > 64 chars + -- In production, verify data before running this + ALTER TABLE auth.sessions ALTER COLUMN session_token TYPE VARCHAR(64); + + -- Recreate index + CREATE INDEX idx_sessions_token ON auth.sessions(session_token); + + RAISE NOTICE 'Renamed token_hash to session_token and shrunk to VARCHAR(64)'; + END IF; +END $$; + +-- ============================================ +-- 3. Remove new columns +-- ============================================ + +ALTER TABLE auth.sessions DROP COLUMN IF EXISTS device_name; +ALTER TABLE auth.sessions DROP COLUMN IF EXISTS browser; +ALTER TABLE auth.sessions DROP COLUMN IF EXISTS os; +ALTER TABLE auth.sessions DROP COLUMN IF EXISTS location; +ALTER TABLE auth.sessions DROP COLUMN IF EXISTS revoked_at; +ALTER TABLE auth.sessions DROP COLUMN IF EXISTS revoked_reason; + +-- ============================================ +-- 4. Restore original cleanup function +-- ============================================ + +CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions() +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + WITH deleted AS ( + DELETE FROM auth.sessions + WHERE expires_at < NOW() - INTERVAL '7 days' + OR (is_active = false AND updated_at < NOW() - INTERVAL '30 days') + RETURNING * + ) + SELECT COUNT(*) INTO deleted_count FROM deleted; + + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; + +RAISE NOTICE 'Rollback of V20260120_001 completed'; + +COMMIT; diff --git a/migrations/V20260120_002__migrate_billing_subscriptions.sql b/migrations/V20260120_002__migrate_billing_subscriptions.sql new file mode 100644 index 0000000..0aac56c --- /dev/null +++ b/migrations/V20260120_002__migrate_billing_subscriptions.sql @@ -0,0 +1,228 @@ +-- ============================================ +-- Migration: V20260120_002 +-- Description: Migrate billing.subscriptions structure +-- Changes: +-- - Add stripe_subscription_id, stripe_customer_id columns +-- - Add interval column (ENUM: month, year) +-- - Add trial_start, cancel_at, cancel_reason columns +-- - Add price_amount, currency columns +-- - Rename cancelled_at -> canceled_at +-- - Change billing.subscription_status enum value: 'trial' -> 'trialing' +-- ============================================ + +-- UP Migration +BEGIN; + +-- ============================================ +-- 1. Ensure billing_interval ENUM exists +-- ============================================ + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'billing_interval' + AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'billing') + ) THEN + CREATE TYPE billing.billing_interval AS ENUM ('month', 'year'); + RAISE NOTICE 'Created billing.billing_interval enum'; + END IF; +END $$; + +-- ============================================ +-- 2. Fix billing.subscription_status enum (trial -> trialing) +-- ============================================ + +DO $$ +DECLARE + has_trial_value BOOLEAN; +BEGIN + -- Check if 'trial' exists in the enum (needs migration) + SELECT EXISTS ( + SELECT 1 + FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + JOIN pg_namespace n ON t.typnamespace = n.oid + WHERE n.nspname = 'billing' + AND t.typname = 'subscription_status' + AND e.enumlabel = 'trial' + ) INTO has_trial_value; + + IF has_trial_value THEN + -- PostgreSQL doesn't allow renaming enum values directly + -- We need to create a new type and migrate + + -- Step 1: Create new enum type + CREATE TYPE billing.subscription_status_new AS ENUM ('trialing', 'active', 'past_due', 'cancelled', 'expired'); + + -- Step 2: Update columns using the old enum + -- First, change column to use text temporarily + ALTER TABLE billing.subscriptions + ALTER COLUMN status TYPE VARCHAR(50) USING status::VARCHAR(50); + + -- Step 3: Update 'trial' to 'trialing' + UPDATE billing.subscriptions + SET status = 'trialing' + WHERE status = 'trial'; + + -- Step 4: Drop old type + DROP TYPE IF EXISTS billing.subscription_status; + + -- Step 5: Rename new type + ALTER TYPE billing.subscription_status_new RENAME TO subscription_status; + + -- Step 6: Convert column back to enum + ALTER TABLE billing.subscriptions + ALTER COLUMN status TYPE billing.subscription_status + USING status::billing.subscription_status; + + -- Step 7: Restore default + ALTER TABLE billing.subscriptions + ALTER COLUMN status SET DEFAULT 'trialing'::billing.subscription_status; + + RAISE NOTICE 'Migrated subscription_status enum: trial -> trialing'; + END IF; +END $$; + +-- ============================================ +-- 3. Add Stripe columns if they don't exist +-- ============================================ + +-- stripe_subscription_id +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'billing' AND table_name = 'subscriptions' AND column_name = 'stripe_subscription_id' + ) THEN + ALTER TABLE billing.subscriptions ADD COLUMN stripe_subscription_id VARCHAR(255) UNIQUE; + CREATE INDEX idx_subscriptions_stripe ON billing.subscriptions(stripe_subscription_id); + RAISE NOTICE 'Added stripe_subscription_id column'; + END IF; +END $$; + +-- stripe_customer_id +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'billing' AND table_name = 'subscriptions' AND column_name = 'stripe_customer_id' + ) THEN + ALTER TABLE billing.subscriptions ADD COLUMN stripe_customer_id VARCHAR(255); + RAISE NOTICE 'Added stripe_customer_id column'; + END IF; +END $$; + +-- ============================================ +-- 4. Add interval column +-- ============================================ + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'billing' AND table_name = 'subscriptions' AND column_name = 'interval' + ) THEN + ALTER TABLE billing.subscriptions ADD COLUMN "interval" billing.billing_interval DEFAULT 'month'; + RAISE NOTICE 'Added interval column'; + END IF; +END $$; + +-- ============================================ +-- 5. Add trial and cancellation columns +-- ============================================ + +-- trial_start +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'billing' AND table_name = 'subscriptions' AND column_name = 'trial_start' + ) THEN + ALTER TABLE billing.subscriptions ADD COLUMN trial_start TIMESTAMPTZ; + RAISE NOTICE 'Added trial_start column'; + END IF; +END $$; + +-- cancel_at (scheduled cancellation date) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'billing' AND table_name = 'subscriptions' AND column_name = 'cancel_at' + ) THEN + ALTER TABLE billing.subscriptions ADD COLUMN cancel_at TIMESTAMPTZ; + RAISE NOTICE 'Added cancel_at column'; + END IF; +END $$; + +-- cancel_reason +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'billing' AND table_name = 'subscriptions' AND column_name = 'cancel_reason' + ) THEN + ALTER TABLE billing.subscriptions ADD COLUMN cancel_reason VARCHAR(500); + RAISE NOTICE 'Added cancel_reason column'; + END IF; +END $$; + +-- ============================================ +-- 6. Add pricing columns +-- ============================================ + +-- price_amount +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'billing' AND table_name = 'subscriptions' AND column_name = 'price_amount' + ) THEN + ALTER TABLE billing.subscriptions ADD COLUMN price_amount DECIMAL(10, 2); + RAISE NOTICE 'Added price_amount column'; + END IF; +END $$; + +-- currency +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'billing' AND table_name = 'subscriptions' AND column_name = 'currency' + ) THEN + ALTER TABLE billing.subscriptions ADD COLUMN currency VARCHAR(3) DEFAULT 'USD'; + RAISE NOTICE 'Added currency column'; + END IF; +END $$; + +-- ============================================ +-- 7. Rename cancelled_at -> canceled_at +-- ============================================ + +DO $$ +BEGIN + -- Check if cancelled_at exists (British spelling) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'billing' AND table_name = 'subscriptions' AND column_name = 'cancelled_at' + ) THEN + ALTER TABLE billing.subscriptions RENAME COLUMN cancelled_at TO canceled_at; + RAISE NOTICE 'Renamed cancelled_at to canceled_at'; + END IF; +END $$; + +-- ============================================ +-- 8. Update comments +-- ============================================ + +COMMENT ON COLUMN billing.subscriptions.stripe_subscription_id IS 'Stripe subscription ID for payment tracking'; +COMMENT ON COLUMN billing.subscriptions.stripe_customer_id IS 'Stripe customer ID'; +COMMENT ON COLUMN billing.subscriptions."interval" IS 'Billing interval: month or year'; +COMMENT ON COLUMN billing.subscriptions.trial_start IS 'When the trial period started'; +COMMENT ON COLUMN billing.subscriptions.cancel_at IS 'Scheduled cancellation date (end of period)'; +COMMENT ON COLUMN billing.subscriptions.cancel_reason IS 'Reason for cancellation'; +COMMENT ON COLUMN billing.subscriptions.price_amount IS 'Price amount at subscription time'; +COMMENT ON COLUMN billing.subscriptions.currency IS 'Currency code (ISO 4217)'; + +COMMIT; diff --git a/migrations/V20260120_002__migrate_billing_subscriptions_DOWN.sql b/migrations/V20260120_002__migrate_billing_subscriptions_DOWN.sql new file mode 100644 index 0000000..ff72fa4 --- /dev/null +++ b/migrations/V20260120_002__migrate_billing_subscriptions_DOWN.sql @@ -0,0 +1,113 @@ +-- ============================================ +-- Migration: V20260120_002 - DOWN (Rollback) +-- Description: Revert billing.subscriptions structure +-- Changes (reversed): +-- - Remove stripe_subscription_id, stripe_customer_id columns +-- - Remove interval column +-- - Remove trial_start, cancel_at, cancel_reason columns +-- - Remove price_amount, currency columns +-- - Rename canceled_at -> cancelled_at +-- - Revert billing.subscription_status enum: 'trialing' -> 'trial' +-- ============================================ + +-- DOWN Migration (Rollback) +BEGIN; + +-- ============================================ +-- 1. Rename canceled_at -> cancelled_at (revert to British) +-- ============================================ + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'billing' AND table_name = 'subscriptions' AND column_name = 'canceled_at' + ) THEN + ALTER TABLE billing.subscriptions RENAME COLUMN canceled_at TO cancelled_at; + RAISE NOTICE 'Renamed canceled_at back to cancelled_at'; + END IF; +END $$; + +-- ============================================ +-- 2. Remove pricing columns +-- ============================================ + +ALTER TABLE billing.subscriptions DROP COLUMN IF EXISTS currency; +ALTER TABLE billing.subscriptions DROP COLUMN IF EXISTS price_amount; + +-- ============================================ +-- 3. Remove trial and cancellation columns +-- ============================================ + +ALTER TABLE billing.subscriptions DROP COLUMN IF EXISTS cancel_reason; +ALTER TABLE billing.subscriptions DROP COLUMN IF EXISTS cancel_at; +ALTER TABLE billing.subscriptions DROP COLUMN IF EXISTS trial_start; + +-- ============================================ +-- 4. Remove interval column +-- ============================================ + +ALTER TABLE billing.subscriptions DROP COLUMN IF EXISTS "interval"; + +-- ============================================ +-- 5. Remove Stripe columns +-- ============================================ + +DROP INDEX IF EXISTS billing.idx_subscriptions_stripe; +ALTER TABLE billing.subscriptions DROP COLUMN IF EXISTS stripe_customer_id; +ALTER TABLE billing.subscriptions DROP COLUMN IF EXISTS stripe_subscription_id; + +-- ============================================ +-- 6. Revert subscription_status enum (trialing -> trial) +-- ============================================ + +DO $$ +DECLARE + has_trialing_value BOOLEAN; +BEGIN + -- Check if 'trialing' exists in the enum (needs rollback) + SELECT EXISTS ( + SELECT 1 + FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + JOIN pg_namespace n ON t.typnamespace = n.oid + WHERE n.nspname = 'billing' + AND t.typname = 'subscription_status' + AND e.enumlabel = 'trialing' + ) INTO has_trialing_value; + + IF has_trialing_value THEN + -- Create old enum type with 'trial' + CREATE TYPE billing.subscription_status_old AS ENUM ('trial', 'active', 'past_due', 'cancelled', 'expired'); + + -- Convert column to text + ALTER TABLE billing.subscriptions + ALTER COLUMN status TYPE VARCHAR(50) USING status::VARCHAR(50); + + -- Revert 'trialing' to 'trial' + UPDATE billing.subscriptions + SET status = 'trial' + WHERE status = 'trialing'; + + -- Drop current type + DROP TYPE IF EXISTS billing.subscription_status; + + -- Rename old type back + ALTER TYPE billing.subscription_status_old RENAME TO subscription_status; + + -- Convert column back to enum + ALTER TABLE billing.subscriptions + ALTER COLUMN status TYPE billing.subscription_status + USING status::billing.subscription_status; + + -- Restore default + ALTER TABLE billing.subscriptions + ALTER COLUMN status SET DEFAULT 'trial'::billing.subscription_status; + + RAISE NOTICE 'Reverted subscription_status enum: trialing -> trial'; + END IF; +END $$; + +RAISE NOTICE 'Rollback of V20260120_002 completed'; + +COMMIT; diff --git a/migrations/V20260120_003__migrate_audit_logs.sql b/migrations/V20260120_003__migrate_audit_logs.sql new file mode 100644 index 0000000..acfde85 --- /dev/null +++ b/migrations/V20260120_003__migrate_audit_logs.sql @@ -0,0 +1,387 @@ +-- ============================================ +-- 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; diff --git a/migrations/V20260120_003__migrate_audit_logs_DOWN.sql b/migrations/V20260120_003__migrate_audit_logs_DOWN.sql new file mode 100644 index 0000000..c4fcd19 --- /dev/null +++ b/migrations/V20260120_003__migrate_audit_logs_DOWN.sql @@ -0,0 +1,163 @@ +-- ============================================ +-- Migration: V20260120_003 - DOWN (Rollback) +-- Description: Revert audit.audit_logs structure to legacy +-- Changes (reversed): +-- - Rename resource_type -> entity_type +-- - Rename resource_id -> entity_id +-- - Remove user_email, actor_type columns +-- - Remove resource_name, severity columns +-- - Remove request_id, session_id columns +-- - Remove endpoint, http_method, response_status, duration_ms columns +-- - Restore changes JSONB from old_values, new_values, changed_fields +-- ============================================ + +-- DOWN Migration (Rollback) +BEGIN; + +-- ============================================ +-- 1. Restore 'changes' column from split columns +-- ============================================ + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'old_values' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'changes' + ) THEN + -- Add changes column + ALTER TABLE audit.audit_logs ADD COLUMN changes JSONB; + + -- Migrate data back into changes + UPDATE audit.audit_logs + SET changes = jsonb_build_object( + 'old', COALESCE(old_values, '{}'::jsonb), + 'new', COALESCE(new_values, '{}'::jsonb), + 'fields', COALESCE(changed_fields, '[]'::jsonb) + ) + WHERE old_values IS NOT NULL OR new_values IS NOT NULL OR changed_fields IS NOT NULL; + + -- Drop new columns + ALTER TABLE audit.audit_logs DROP COLUMN old_values; + ALTER TABLE audit.audit_logs DROP COLUMN new_values; + ALTER TABLE audit.audit_logs DROP COLUMN changed_fields; + + RAISE NOTICE 'Restored changes column from old_values, new_values, changed_fields'; + END IF; +END $$; + +-- ============================================ +-- 2. Remove HTTP context columns +-- ============================================ + +ALTER TABLE audit.audit_logs DROP COLUMN IF EXISTS duration_ms; +ALTER TABLE audit.audit_logs DROP COLUMN IF EXISTS response_status; +ALTER TABLE audit.audit_logs DROP COLUMN IF EXISTS http_method; +ALTER TABLE audit.audit_logs DROP COLUMN IF EXISTS endpoint; + +-- ============================================ +-- 3. Remove context columns +-- ============================================ + +ALTER TABLE audit.audit_logs DROP COLUMN IF EXISTS session_id; +ALTER TABLE audit.audit_logs DROP COLUMN IF EXISTS request_id; + +-- ============================================ +-- 4. Remove severity column and index +-- ============================================ + +DROP INDEX IF EXISTS audit.idx_audit_logs_severity; +ALTER TABLE audit.audit_logs DROP COLUMN IF EXISTS severity; + +-- ============================================ +-- 5. Remove resource_name column +-- ============================================ + +ALTER TABLE audit.audit_logs DROP COLUMN IF EXISTS resource_name; + +-- ============================================ +-- 6. Remove actor columns +-- ============================================ + +ALTER TABLE audit.audit_logs DROP COLUMN IF EXISTS actor_type; +ALTER TABLE audit.audit_logs DROP COLUMN IF EXISTS user_email; + +-- ============================================ +-- 7. Rename resource_id -> entity_id +-- ============================================ + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'resource_id' + ) THEN + ALTER TABLE audit.audit_logs RENAME COLUMN resource_id TO entity_id; + RAISE NOTICE 'Renamed resource_id back to entity_id'; + END IF; +END $$; + +-- ============================================ +-- 8. Rename resource_type -> entity_type +-- ============================================ + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'resource_type' + ) THEN + -- Drop new index + DROP INDEX IF EXISTS audit.idx_audit_logs_resource; + + -- Rename column + ALTER TABLE audit.audit_logs RENAME COLUMN resource_type TO entity_type; + + -- Create old index + CREATE INDEX idx_audit_logs_entity ON audit.audit_logs(tenant_id, entity_type, entity_id); + + RAISE NOTICE 'Renamed resource_type back to entity_type'; + END IF; +END $$; + +-- ============================================ +-- 9. Restore original log_event function +-- ============================================ + +CREATE OR REPLACE FUNCTION audit.log_event( + p_tenant_id UUID, + p_user_id UUID, + p_action audit.action_type, + p_entity_type VARCHAR, + p_entity_id VARCHAR DEFAULT NULL, + p_description TEXT DEFAULT NULL, + p_changes JSONB DEFAULT NULL, + p_metadata JSONB DEFAULT '{}'::jsonb +) +RETURNS UUID AS $$ +DECLARE + v_id UUID; +BEGIN + INSERT INTO audit.audit_logs ( + tenant_id, user_id, action, entity_type, entity_id, + description, changes, metadata + ) VALUES ( + p_tenant_id, p_user_id, p_action, p_entity_type, p_entity_id, + p_description, p_changes, p_metadata + ) RETURNING id INTO v_id; + + RETURN v_id; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- 10. Update comment +-- ============================================ + +COMMENT ON COLUMN audit.audit_logs.changes IS 'JSON diff of field changes'; + +RAISE NOTICE 'Rollback of V20260120_003 completed'; + +COMMIT;