[TEMPLATE-SAAS-DB] chore: Update audit schema and add migrations
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3ce06fbce4
commit
27de049441
@ -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;
|
||||
|
||||
81
migrations/README.md
Normal file
81
migrations/README.md
Normal file
@ -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`
|
||||
252
migrations/V20260120_001__migrate_auth_sessions_structure.sql
Normal file
252
migrations/V20260120_001__migrate_auth_sessions_structure.sql
Normal file
@ -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;
|
||||
@ -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;
|
||||
228
migrations/V20260120_002__migrate_billing_subscriptions.sql
Normal file
228
migrations/V20260120_002__migrate_billing_subscriptions.sql
Normal file
@ -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;
|
||||
113
migrations/V20260120_002__migrate_billing_subscriptions_DOWN.sql
Normal file
113
migrations/V20260120_002__migrate_billing_subscriptions_DOWN.sql
Normal file
@ -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;
|
||||
387
migrations/V20260120_003__migrate_audit_logs.sql
Normal file
387
migrations/V20260120_003__migrate_audit_logs.sql
Normal file
@ -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;
|
||||
163
migrations/V20260120_003__migrate_audit_logs_DOWN.sql
Normal file
163
migrations/V20260120_003__migrate_audit_logs_DOWN.sql
Normal file
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user