[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:
rckrdmrd 2026-01-20 04:38:47 -06:00
parent 3ce06fbce4
commit 27de049441
8 changed files with 1356 additions and 4 deletions

View File

@ -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
View 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`

View 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;

View File

@ -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;

View 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;

View 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;

View 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;

View 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;