diff --git a/crates/db/migrations/20260318235959_customer_profiles_status_early.down.sql b/crates/db/migrations/20260318235959_customer_profiles_status_early.down.sql new file mode 100644 index 0000000..b397bc3 --- /dev/null +++ b/crates/db/migrations/20260318235959_customer_profiles_status_early.down.sql @@ -0,0 +1,3 @@ +-- No-op: 20260721020000_customer_profiles_status.down.sql already owns +-- dropping this column. Reverting it here too would break that migration's +-- own down.sql if both are ever rolled back in the same pass. diff --git a/crates/db/migrations/20260318235959_customer_profiles_status_early.up.sql b/crates/db/migrations/20260318235959_customer_profiles_status_early.up.sql new file mode 100644 index 0000000..1dcb72a --- /dev/null +++ b/crates/db/migrations/20260318235959_customer_profiles_status_early.up.sql @@ -0,0 +1,13 @@ +-- 20260319090000_backfill_active_profiles_to_approved.up.sql UPDATEs +-- customer_profiles.status, but that column isn't ADDed until +-- 20260721020000_customer_profiles_status -- four months later in this +-- chain. Any environment bootstrapping fresh via `sqlx migrate run` hits +-- "column status does not exist" at 20260319090000, regardless of whether +-- it's ever seeded by anything else first. +-- +-- This runs one migration before that backfill and adds the column early +-- (IF NOT EXISTS, matching 20260721020000's own guard exactly), so that +-- migration and everything after it can proceed. 20260721020000 then +-- becomes a safe no-op when it's reached later in the same run. +ALTER TABLE customer_profiles + ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'DRAFT'; diff --git a/crates/db/migrations/20260401235959_drop_old_employees_table_early.down.sql b/crates/db/migrations/20260401235959_drop_old_employees_table_early.down.sql new file mode 100644 index 0000000..4923195 --- /dev/null +++ b/crates/db/migrations/20260401235959_drop_old_employees_table_early.down.sql @@ -0,0 +1,4 @@ +-- No down: this only drops a table that 20260402030000 immediately +-- recreates in the new shape. There's no old-shape data to restore -- +-- the transition it replays already happened in every real environment +-- long before this migration existed. diff --git a/crates/db/migrations/20260401235959_drop_old_employees_table_early.up.sql b/crates/db/migrations/20260401235959_drop_old_employees_table_early.up.sql new file mode 100644 index 0000000..be1de1c --- /dev/null +++ b/crates/db/migrations/20260401235959_drop_old_employees_table_early.up.sql @@ -0,0 +1,30 @@ +-- 20260402030000_strict_employee_separation's own comment documents this: +-- the migration originally did `DROP TABLE employees CASCADE` to replace +-- the old users-linked employees (created by +-- 20260317190000_complete_users_schema) with the new standalone-auth +-- shape. That DROP was later removed from the file because the old +-- db-migrate tool replayed every .sql on every run, turning a one-time +-- transformation into one that wiped live employee accounts on every +-- deploy. Production made this transition long ago, so the file was +-- simplified to a plain `CREATE TABLE IF NOT EXISTS`. +-- +-- But on any environment that hasn't made that transition yet -- a fresh +-- bootstrap chief among them -- 20260317190000's old-shape `employees` +-- (user_id/role_id, no email/password_hash) is still sitting there, so +-- 20260402030000's CREATE TABLE IF NOT EXISTS no-ops against it and the +-- very next statement (CREATE INDEX ... employees(email)) fails. +-- +-- This restores the original one-time DROP, but gated: it only fires if +-- employees still has the old pre-transition shape (no `email` column). +-- Any environment that already transitioned (all real ones today, per the +-- comment above) is untouched. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'employees') + AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'employees' AND column_name = 'email' + ) THEN + DROP TABLE employees CASCADE; + END IF; +END $$; diff --git a/crates/db/migrations/20260419235959_external_role_module_tables_early.down.sql b/crates/db/migrations/20260419235959_external_role_module_tables_early.down.sql new file mode 100644 index 0000000..d380a10 --- /dev/null +++ b/crates/db/migrations/20260419235959_external_role_module_tables_early.down.sql @@ -0,0 +1,5 @@ +-- No down: this only ever creates objects that either (a) the disabled +-- 20260420000003_external_role_modules.up.sql.skip already defines the +-- teardown intent for, or (b) already existed independently of this +-- migration in any environment where it's a no-op. Nothing here is safe +-- to unilaterally drop without knowing which case applies. diff --git a/crates/db/migrations/20260419235959_external_role_module_tables_early.up.sql b/crates/db/migrations/20260419235959_external_role_module_tables_early.up.sql new file mode 100644 index 0000000..7766868 --- /dev/null +++ b/crates/db/migrations/20260419235959_external_role_module_tables_early.up.sql @@ -0,0 +1,164 @@ +-- 20260420000003_external_role_modules.up.sql was renamed to .up.sql.skip +-- (its version slot was already claimed by ...seed.sql), which meant this +-- schema was never actually created by any active migration -- only the +-- seed data that depends on it (persona_types, modules, module_actions, +-- role_module_widgets) runs. Any fresh bootstrap hits "relation ... does +-- not exist" at that seed step. This is the .skip file's content, applied +-- one migration earlier under its own version so the seed step succeeds. +-- Verified: purely additive (CREATE TABLE/INDEX IF NOT EXISTS, ADD COLUMN +-- IF NOT EXISTS throughout), safe no-op on any environment that already +-- has this schema by other means. + +-- ============================================ +-- ADD COLUMNS TO ROLES for external role settings +-- ============================================ +ALTER TABLE roles ADD COLUMN IF NOT EXISTS persona_type varchar(50); +ALTER TABLE roles ADD COLUMN IF NOT EXISTS onboarding_schema_key varchar(100); +ALTER TABLE roles ADD COLUMN IF NOT EXISTS verification_required boolean DEFAULT true; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS switch_services_enabled boolean DEFAULT false; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS is_publicly_discoverable boolean DEFAULT true; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS external_role_description text; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS sort_order integer DEFAULT 0; + +-- ============================================ +-- persona_types (categories for external roles) +-- ============================================ +CREATE TABLE IF NOT EXISTS persona_types ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(50) UNIQUE NOT NULL, + name varchar(100) NOT NULL, + description text, + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT NOW(), + updated_at timestamptz DEFAULT NOW() +); + +-- ============================================ +-- modules (module registry) +-- ============================================ +CREATE TABLE IF NOT EXISTS modules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + module_key varchar(50) UNIQUE NOT NULL, + module_name varchar(100) NOT NULL, + category varchar(50), -- core/content/marketplace/work/financial + description text, + backend_domain varchar(100), + default_route varchar(255), + default_sidebar_label varchar(100), + icon_key varchar(50), + is_core boolean DEFAULT false, + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT NOW(), + updated_at timestamptz DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_modules_category ON modules(category); +CREATE INDEX IF NOT EXISTS idx_modules_active ON modules(is_active); + +-- ============================================ +-- role_module_access (module visibility per role) +-- ============================================ +CREATE TABLE IF NOT EXISTS role_module_access ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + is_enabled boolean DEFAULT true, + is_sidebar_visible boolean DEFAULT true, + sidebar_label_override varchar(100), + route_override varchar(255), + sort_order integer DEFAULT 0, + created_at timestamptz DEFAULT NOW(), + UNIQUE(role_id, module_id) +); + +CREATE INDEX IF NOT EXISTS idx_role_module_access_role ON role_module_access(role_id); +CREATE INDEX IF NOT EXISTS idx_role_module_access_module ON role_module_access(module_id); + +-- ============================================ +-- module_actions (CRUD actions per module) +-- ============================================ +CREATE TABLE IF NOT EXISTS module_actions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + action_key varchar(50) NOT NULL, + action_name varchar(100) NOT NULL, + description text, + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT NOW(), + UNIQUE(module_id, action_key) +); + +CREATE INDEX IF NOT EXISTS idx_module_actions_module ON module_actions(module_id); + +-- ============================================ +-- role_module_permissions (permissions per module per role) +-- ============================================ +CREATE TABLE IF NOT EXISTS role_module_permissions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + can_view boolean DEFAULT false, + can_list boolean DEFAULT false, + can_create boolean DEFAULT false, + can_update boolean DEFAULT false, + can_delete boolean DEFAULT false, + extra_actions_json jsonb DEFAULT '{}', + created_at timestamptz DEFAULT NOW(), + UNIQUE(role_id, module_id) +); + +CREATE INDEX IF NOT EXISTS idx_role_module_permissions_role ON role_module_permissions(role_id); +CREATE INDEX IF NOT EXISTS idx_role_module_permissions_module ON role_module_permissions(module_id); + +-- ============================================ +-- role_module_widgets (widgets per module per role) +-- ============================================ +CREATE TABLE IF NOT EXISTS role_module_widgets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + widget_key varchar(50), + is_enabled boolean DEFAULT true, + sort_order integer DEFAULT 0, + created_at timestamptz DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_role_module_widgets_role ON role_module_widgets(role_id); +CREATE INDEX IF NOT EXISTS idx_role_module_widgets_module ON role_module_widgets(module_id); + +-- ============================================ +-- module_variants (role-specific module variants) +-- ============================================ +CREATE TABLE IF NOT EXISTS module_variants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + variant_key varchar(50) NOT NULL, + variant_name varchar(100) NOT NULL, + role_code varchar(50), -- target role (e.g., PHOTOGRAPHER, TUTOR) + persona_type varchar(50), -- target persona (e.g., PROFESSIONAL) + schema_key varchar(100), + ui_template_key varchar(100), + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT NOW(), + updated_at timestamptz DEFAULT NOW(), + UNIQUE(module_id, variant_key) +); + +CREATE INDEX IF NOT EXISTS idx_module_variants_module ON module_variants(module_id); +CREATE INDEX IF NOT EXISTS idx_module_variants_role ON module_variants(role_code); + +-- ============================================ +-- role_module_variant_mapping (which variants a role uses) +-- ============================================ +CREATE TABLE IF NOT EXISTS role_module_variant_mapping ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + module_variant_id uuid NOT NULL REFERENCES module_variants(id) ON DELETE CASCADE, + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT NOW(), + UNIQUE(role_id, module_id, module_variant_id) +); + +CREATE INDEX IF NOT EXISTS idx_role_module_variant_mapping_role ON role_module_variant_mapping(role_id); +CREATE INDEX IF NOT EXISTS idx_role_module_variant_mapping_module ON role_module_variant_mapping(module_id); diff --git a/crates/db/migrations/20260421235959_role_config_table_renames_early.down.sql b/crates/db/migrations/20260421235959_role_config_table_renames_early.down.sql new file mode 100644 index 0000000..3cdb72d --- /dev/null +++ b/crates/db/migrations/20260421235959_role_config_table_renames_early.down.sql @@ -0,0 +1,4 @@ +-- No down: reversing these renames would break whichever later migration +-- or app code has since come to depend on the new names. See +-- 20260420000002_cleanup_role_tables.down.sql if a full rollback of the +-- rename itself is ever actually needed. diff --git a/crates/db/migrations/20260421235959_role_config_table_renames_early.up.sql b/crates/db/migrations/20260421235959_role_config_table_renames_early.up.sql new file mode 100644 index 0000000..40b50e8 --- /dev/null +++ b/crates/db/migrations/20260421235959_role_config_table_renames_early.up.sql @@ -0,0 +1,37 @@ +-- 20260420000002_cleanup_role_tables.up.sql (the rename half relevant here) +-- was disabled as .up.sql.skip, so role_permissions/dashboard_configs/ +-- runtime_configs/user_roles never get renamed to the names the live app +-- code (config.rs, external_roles.rs, user_roles.rs, ...) actually queries: +-- role_admin_permissions/role_sidebar_configs/role_runtime_configs/ +-- user_role_assignments. 20260422000000_seed_widgets needs +-- role_sidebar_configs to exist, so any fresh bootstrap fails there. +-- +-- Unlike the external-role-module tables migration, this can't just be +-- "IF NOT EXISTS" -- a RENAME's source table stops existing once it's +-- done, so re-running it errors on an environment where it already +-- happened (including this repo's own production, per the skip file's own +-- history). Each rename below only fires if the old name still exists and +-- the new name doesn't yet -- safe to run on any environment regardless of +-- which (if any) of these renames it already has. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'role_permissions') + AND NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'role_admin_permissions') THEN + ALTER TABLE role_permissions RENAME TO role_admin_permissions; + END IF; + + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'dashboard_configs') + AND NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'role_sidebar_configs') THEN + ALTER TABLE dashboard_configs RENAME TO role_sidebar_configs; + END IF; + + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'runtime_configs') + AND NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'role_runtime_configs') THEN + ALTER TABLE runtime_configs RENAME TO role_runtime_configs; + END IF; + + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'user_roles') + AND NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'user_role_assignments') THEN + ALTER TABLE user_roles RENAME TO user_role_assignments; + END IF; +END $$; diff --git a/docs/LIVE_SERVER_RUNBOOK.md b/docs/LIVE_SERVER_RUNBOOK.md index 44843c8..621919d 100644 --- a/docs/LIVE_SERVER_RUNBOOK.md +++ b/docs/LIVE_SERVER_RUNBOOK.md @@ -20,9 +20,9 @@ All four DB tasks below are done and confirmed against the live production DB. --- -## ⏳ Pending — DB migrations for AI credit purchases (2026-08-14) +## ⏳ Pending — DB migrations for AI credit purchases + coupons + fresh-bootstrap fixes (2026-08-14) -Three new migrations need to be applied to prod. Run **in order** (sqlx-migrate handles this, but listed explicitly so you can verify): +Eight new migrations need to be applied to prod. Run **in order** (sqlx-migrate handles this, but listed explicitly so you can verify): ```bash # On the live server, inside nxtgauge-backend-rust: @@ -30,19 +30,30 @@ git pull origin high-performance sqlx migrate run --database-url "$DATABASE_URL" ``` -That single command applies all three in chronological order. Verify after: +That single command applies all eight in chronological order. Verify after: ```bash sqlx migrate info --database-url "$DATABASE_URL" ``` -All three should show `applied`. +All eight should show `applied`. On prod specifically, the four "early" fixes +below should all apply as **no-ops** — prod already has the target schema +(that's how their gating conditions were derived and verified). If any of +them actually *does* something on prod (i.e. its `Applied` log line isn't +near-instant), stop and check `\d