-- Plan purchases use a separate payment-event table because the recharge -- payment_events.order_id foreign key intentionally points at recharge_orders. ALTER TABLE IF EXISTS plan_purchases ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'adapter'; ALTER TABLE IF EXISTS plan_purchases ADD COLUMN IF NOT EXISTS plan_snapshot JSONB; ALTER TABLE IF EXISTS plan_purchases ADD COLUMN IF NOT EXISTS credit_bucket_id UUID; ALTER TABLE IF EXISTS plan_purchases ADD COLUMN IF NOT EXISTS entitlement_id UUID; ALTER TABLE IF EXISTS recharge_orders ADD COLUMN IF NOT EXISTS credit_bucket_id UUID; ALTER TABLE IF EXISTS user_plan_entitlements ADD COLUMN IF NOT EXISTS snapshot JSONB; CREATE TABLE IF NOT EXISTS plan_payment_events ( id UUID PRIMARY KEY, provider TEXT NOT NULL, external_event_id TEXT NOT NULL, purchase_id UUID NOT NULL REFERENCES plan_purchases(id), payload_digest TEXT NOT NULL, status TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), applied_at TIMESTAMPTZ, UNIQUE(provider, external_event_id) ); -- Runtime records that are part of the server's durable state but are not -- user-facing business aggregates. They keep authentication challenges, -- provider configuration and staging metadata relationally addressable while -- preserving encrypted payloads and ownership boundaries. ALTER TABLE IF EXISTS verification_codes ADD COLUMN IF NOT EXISTS record_key TEXT; ALTER TABLE IF EXISTS verification_codes ADD COLUMN IF NOT EXISTS record_kind TEXT NOT NULL DEFAULT 'verification'; ALTER TABLE IF EXISTS verification_codes ADD COLUMN IF NOT EXISTS ip TEXT; ALTER TABLE IF EXISTS verification_codes ADD COLUMN IF NOT EXISTS device_id TEXT; ALTER TABLE IF EXISTS verification_codes ADD COLUMN IF NOT EXISTS max_attempts INTEGER NOT NULL DEFAULT 5; CREATE INDEX IF NOT EXISTS verification_codes_record_key_idx ON verification_codes(record_kind, record_key); ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS mfa_secret_encrypted TEXT; ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS mfa_recovery_code_hashes JSONB; ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS mfa_last_totp_counter BIGINT; ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS mfa_required BOOLEAN NOT NULL DEFAULT false; ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN NOT NULL DEFAULT false; ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS roles JSONB NOT NULL DEFAULT '[]'::jsonb; ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS failed_login_count INTEGER NOT NULL DEFAULT 0; ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS locked_until BIGINT; ALTER TABLE IF EXISTS sessions ADD COLUMN IF NOT EXISTS replaced_by TEXT; ALTER TABLE IF EXISTS sessions ADD COLUMN IF NOT EXISTS roles JSONB; ALTER TABLE IF EXISTS sessions ADD COLUMN IF NOT EXISTS scopes JSONB; CREATE TABLE IF NOT EXISTS captcha_challenges ( id TEXT PRIMARY KEY, action TEXT NOT NULL, answer_hash TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 5, consumed_at TIMESTAMPTZ, ip TEXT NOT NULL, device_id TEXT ); CREATE TABLE IF NOT EXISTS mfa_challenges ( id TEXT PRIMARY KEY, subject_id TEXT NOT NULL, scope TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, attempts INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS session_families ( family_id TEXT PRIMARY KEY, subject_id TEXT NOT NULL, scope TEXT NOT NULL, revoked_at TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS admin_accounts ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL, mfa_secret_encrypted TEXT, mfa_required BOOLEAN NOT NULL DEFAULT true, recovery_code_hashes JSONB NOT NULL DEFAULT '[]'::jsonb, mfa_last_totp_counter BIGINT ); CREATE TABLE IF NOT EXISTS runtime_records ( record_type TEXT NOT NULL, record_key TEXT NOT NULL, payload JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (record_type, record_key) ); CREATE TABLE IF NOT EXISTS message_providers ( id TEXT PRIMARY KEY, snapshot JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS message_templates ( id TEXT PRIMARY KEY, snapshot JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS payment_providers ( id TEXT PRIMARY KEY, snapshot JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS webdav_files ( user_id TEXT NOT NULL, path TEXT NOT NULL, mime_type TEXT NOT NULL, data TEXT NOT NULL DEFAULT '', checksum TEXT NOT NULL, etag TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, updated_at TIMESTAMPTZ NOT NULL, deleted_at TIMESTAMPTZ, PRIMARY KEY (user_id, path) ); CREATE TABLE IF NOT EXISTS webdav_sync_jobs ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, operation TEXT NOT NULL, path TEXT NOT NULL, data TEXT, mime_type TEXT, if_match TEXT, attempts INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL, next_attempt_at TIMESTAMPTZ NOT NULL, lease_expires_at TIMESTAMPTZ, last_error TEXT, created_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS stored_objects ( id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, mime_type TEXT NOT NULL, data TEXT, staging_key TEXT, expires_at TIMESTAMPTZ NOT NULL, task_id TEXT, revoked BOOLEAN NOT NULL DEFAULT false ); CREATE INDEX IF NOT EXISTS webdav_files_user_idx ON webdav_files(user_id, updated_at DESC); CREATE INDEX IF NOT EXISTS webdav_sync_jobs_user_idx ON webdav_sync_jobs(user_id, status, next_attempt_at); CREATE INDEX IF NOT EXISTS stored_objects_owner_idx ON stored_objects(owner_id, expires_at); ALTER TABLE IF EXISTS webdav_configs ADD COLUMN IF NOT EXISTS manifest_retention_expires_at TIMESTAMPTZ; ALTER TABLE IF EXISTS webdav_configs ADD COLUMN IF NOT EXISTS manifest_extension_days INTEGER NOT NULL DEFAULT 0;