diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 1acbd0d..62b0e5c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -48,6 +48,34 @@ jobs: .catch(e => { console.error("Pre-flight check failed:", e.message); process.exit(1); }); ' echo "=== Running migrations against DATABASE_URL (masked value for logs) ===" + # The migrate runner (scripts/migrate.js) now includes repair logic for + # DBs that had 0001_init.sql applied before _migrations tracking was added. + # We also run a tiny pre-repair here so the step is resilient even if an + # older copy of the runner is present in the checkout. + node -e ' + const { Client } = require("pg"); + const url = process.env.DATABASE_URL; + if (!url) { process.exit(0); } + const c = new Client({ connectionString: url }); + c.connect() + .then(() => c.query("SELECT 1 FROM information_schema.tables WHERE table_name = '\''admin_users'\'' LIMIT 1")) + .then(async (res) => { + if (res.rows.length > 0) { + await c.query(` + CREATE TABLE IF NOT EXISTS _migrations ( + filename TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + INSERT INTO _migrations (filename) + VALUES ('\''0001_init.sql'\'') + ON CONFLICT (filename) DO NOTHING; + `); + console.log("✓ 0001_init.sql tracking repaired (admin_users already present)"); + } + return c.end(); + }) + .catch(() => { /* best effort */ }); + ' npm run migrate:one echo "=== Post-migration verification: critical table admin_users must exist ===" node -e ' diff --git a/MEMORY.md b/MEMORY.md index 3b69bc7..78400f9 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -2,7 +2,35 @@ This file captures key context, decisions, fixes, and state from recent work so it survives across conversations. -**Last updated:** 2026-06-06 (Supabase → Postgres pivot) +**Last updated:** 2026-06 (migration reliability + Google sign-in work) + +## 2026-06: CI migration failures on re-deploy (0001_init.sql "already exists") + +Prod DATABASE_URL already had the schema from the first successful bootstrap. +The deploy workflow runs `npm run migrate:one` on every push (after neon_auth preflight). +`scripts/migrate.js` has `_migrations` tracking + skip, but the row for `0001_init.sql` was never recorded (the tracking logic landed after the initial apply, or an apply happened outside the runner). + +`db/migrations/0001_init.sql` header *claimed* "CREATE TABLE IF NOT EXISTS" but the actual statements were plain `CREATE TABLE`, plain `CREATE INDEX`, and unguarded `CREATE TRIGGER`. + +Result: every subsequent deploy hit `relation "admin_users" already exists` (and would have hit index/trigger dups too) inside the runner's BEGIN, causing ROLLBACK + failure of the whole "Run migrations" job. + +### Fixes applied +- Made `0001_init.sql` truly re-runnable: + - All `CREATE TABLE` → `CREATE TABLE IF NOT EXISTS` + - All `CREATE INDEX` / `CREATE UNIQUE INDEX` → `... IF NOT EXISTS` + - Every `CREATE TRIGGER` wrapped in a `DO $$ IF NOT EXISTS (pg_trigger check) THEN CREATE TRIGGER ... END IF; $$` guard + - Removed the file-level `BEGIN; ... COMMIT;` (the runner owns the tx; this also prevents inner-COMMIT from ending the runner tx early). +- `0002_admin_password.sql` had its tx wrapper removed for consistency (its ALTER was already `IF NOT EXISTS`). +- Hardened `scripts/migrate.js`: + - Added `ensureTracked()` repair: for 0001/0002, if the core objects (admin_users table, or the password_hash column) already exist in the target DB but the tracking row is absent, we INSERT the row (ON CONFLICT DO NOTHING) and skip the file. Logs "repaired tracking". +- Hardened `.gitea/workflows/deploy.yml` "Run migrations" step: + - Added an inline pre-repair node snippet (same idea) right before `npm run migrate:one`. This protects even if an older runner is checked out. + - The existing neon_auth preflight + post `admin_users` verification remain as the hard gate. +- Updated header comments and docs in the files. + +After this, `npm run migrate` / deploys on an already-initialized DB will log the "repaired" or "already applied" lines for 0001 and proceed cleanly. The shipped `scripts/migrate.js` + `db/migrations/` on the target server also benefit for emergency recovery runs. + +See also the plan doc referenced in deploy.yml for the broader reliability work. --- diff --git a/db/migrations/0001_init.sql b/db/migrations/0001_init.sql index 3d29867..f64d4c0 100644 --- a/db/migrations/0001_init.sql +++ b/db/migrations/0001_init.sql @@ -10,7 +10,7 @@ -- - Status enums as TEXT + CHECK (no PG ENUM type) -- - TIMESTAMPTZ everywhere for timestamps -- - gen_random_uuid() for all UUID PKs --- - CREATE OR REPLACE for helpers; CREATE TABLE IF NOT EXISTS for tables +-- - CREATE OR REPLACE for helpers; CREATE TABLE IF NOT EXISTS + guarded triggers for re-runnability -- - All business tables are brand-scoped (brand_id FK → brands.id) -- - SECURITY DEFINER RPCs for all data access -- - RLS on tenant-scoped tables; bypass via is_platform_admin() GUC @@ -21,9 +21,7 @@ -- Platform admin sets app.platform_admin='true' to bypass. -- -- ============================================================================ - -BEGIN; - +-- BEGIN; (transaction controlled by migrate runner) -- ============================================================================ -- 0. Extensions -- ============================================================================ @@ -42,7 +40,7 @@ DROP TABLE IF EXISTS plans CASCADE; DROP TABLE IF EXISTS products CASCADE; DROP TABLE IF EXISTS brands CASCADE; -CREATE TABLE brands ( +CREATE TABLE IF NOT EXISTS brands ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, @@ -64,15 +62,25 @@ CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql; -CREATE TRIGGER brands_updated_at BEFORE UPDATE ON brands +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'brands_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER brands_updated_at BEFORE UPDATE ON brands FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -- ============================================================================ -- 2. Admin Users + Multi-brand -- Links to Neon Auth users table (neon_neon_auth.user) via email. -- ============================================================================ -CREATE TABLE admin_users ( +CREATE TABLE IF NOT EXISTS admin_users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES neon_auth.user(id) ON DELETE CASCADE, email TEXT NOT NULL, @@ -97,10 +105,20 @@ CREATE TABLE admin_users ( UNIQUE (email) ); -CREATE TRIGGER admin_users_updated_at BEFORE UPDATE ON admin_users +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'admin_users_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER admin_users_updated_at BEFORE UPDATE ON admin_users FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE TABLE admin_user_brands ( +CREATE TABLE IF NOT EXISTS admin_user_brands ( admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, added_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -112,7 +130,7 @@ CREATE TABLE admin_user_brands ( -- 3. Products -- ============================================================================ -CREATE TABLE products ( +CREATE TABLE IF NOT EXISTS products ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, name TEXT NOT NULL, @@ -132,17 +150,27 @@ CREATE TABLE products ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER products_updated_at BEFORE UPDATE ON products +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'products_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER products_updated_at BEFORE UPDATE ON products FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE INDEX products_brand_idx ON products (brand_id); -CREATE INDEX products_active_idx ON products (brand_id, active); +CREATE INDEX IF NOT EXISTS products_brand_idx ON products (brand_id); +CREATE INDEX IF NOT EXISTS products_active_idx ON products (brand_id, active); -- ============================================================================ -- 4. Product Images -- ============================================================================ -CREATE TABLE product_images ( +CREATE TABLE IF NOT EXISTS product_images ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, storage_key TEXT NOT NULL, @@ -150,13 +178,13 @@ CREATE TABLE product_images ( alt_text TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX product_images_product_idx ON product_images (product_id); +CREATE INDEX IF NOT EXISTS product_images_product_idx ON product_images (product_id); -- ============================================================================ -- 5. Stops / Locations -- ============================================================================ -CREATE TABLE stops ( +CREATE TABLE IF NOT EXISTS stops ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, name TEXT NOT NULL, @@ -176,14 +204,24 @@ CREATE TABLE stops ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER stops_updated_at BEFORE UPDATE ON stops +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'stops_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER stops_updated_at BEFORE UPDATE ON stops FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE INDEX stops_brand_idx ON stops (brand_id); -CREATE INDEX stops_status_idx ON stops (brand_id, status); -CREATE INDEX stops_date_idx ON stops (brand_id, date); +CREATE INDEX IF NOT EXISTS stops_brand_idx ON stops (brand_id); +CREATE INDEX IF NOT EXISTS stops_status_idx ON stops (brand_id, status); +CREATE INDEX IF NOT EXISTS stops_date_idx ON stops (brand_id, date); -CREATE TABLE locations ( +CREATE TABLE IF NOT EXISTS locations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, name TEXT NOT NULL, @@ -201,14 +239,24 @@ CREATE TABLE locations ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER locations_updated_at BEFORE UPDATE ON locations +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'locations_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER locations_updated_at BEFORE UPDATE ON locations FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -- ============================================================================ -- 5. Customers -- ============================================================================ -CREATE TABLE customers ( +CREATE TABLE IF NOT EXISTS customers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, primary_email TEXT, @@ -222,16 +270,26 @@ CREATE TABLE customers ( CONSTRAINT customers_email_or_phone CHECK (primary_email IS NOT NULL OR primary_phone IS NOT NULL) ); -CREATE TRIGGER customers_updated_at BEFORE UPDATE ON customers +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'customers_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER customers_updated_at BEFORE UPDATE ON customers FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE INDEX customers_brand_idx ON customers (brand_id); +CREATE INDEX IF NOT EXISTS customers_brand_idx ON customers (brand_id); -- ============================================================================ -- 6. Orders -- ============================================================================ -CREATE TABLE orders ( +CREATE TABLE IF NOT EXISTS orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, customer_id UUID REFERENCES customers(id) ON DELETE SET NULL, @@ -251,15 +309,25 @@ CREATE TABLE orders ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER orders_updated_at BEFORE UPDATE ON orders +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'orders_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER orders_updated_at BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE INDEX orders_brand_idx ON orders (brand_id); -CREATE INDEX orders_status_idx ON orders (brand_id, status); -CREATE INDEX orders_stop_idx ON orders (stop_id); -CREATE INDEX orders_customer_idx ON orders (customer_id); +CREATE INDEX IF NOT EXISTS orders_brand_idx ON orders (brand_id); +CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (brand_id, status); +CREATE INDEX IF NOT EXISTS orders_stop_idx ON orders (stop_id); +CREATE INDEX IF NOT EXISTS orders_customer_idx ON orders (customer_id); -CREATE TABLE order_items ( +CREATE TABLE IF NOT EXISTS order_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, product_id UUID REFERENCES products(id) ON DELETE SET NULL, @@ -269,13 +337,13 @@ CREATE TABLE order_items ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX order_items_order_idx ON order_items (order_id); +CREATE INDEX IF NOT EXISTS order_items_order_idx ON order_items (order_id); -- ============================================================================ -- 7. Brand Settings + Features -- ============================================================================ -CREATE TABLE brand_settings ( +CREATE TABLE IF NOT EXISTS brand_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, legal_business_name TEXT, @@ -304,10 +372,20 @@ CREATE TABLE brand_settings ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER brand_settings_updated_at BEFORE UPDATE ON brand_settings +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'brand_settings_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER brand_settings_updated_at BEFORE UPDATE ON brand_settings FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE TABLE brand_features ( +CREATE TABLE IF NOT EXISTS brand_features ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, feature_key TEXT NOT NULL, @@ -322,7 +400,7 @@ CREATE TABLE brand_features ( -- 8. Billing — Plans + Add-ons -- ============================================================================ -CREATE TABLE plans ( +CREATE TABLE IF NOT EXISTS plans ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), code TEXT NOT NULL UNIQUE CHECK (code IN ('starter', 'farm', 'enterprise')), @@ -335,7 +413,7 @@ CREATE TABLE plans ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TABLE add_ons ( +CREATE TABLE IF NOT EXISTS add_ons ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), code TEXT NOT NULL UNIQUE CHECK (code IN ( @@ -348,7 +426,7 @@ CREATE TABLE add_ons ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TABLE brand_add_ons ( +CREATE TABLE IF NOT EXISTS brand_add_ons ( brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, add_on_id UUID NOT NULL REFERENCES add_ons(id) ON DELETE CASCADE, stripe_subscription_id TEXT, @@ -377,7 +455,7 @@ INSERT INTO add_ons (code, name, monthly_price_cents, description) VALUES -- 9. Wholesale Portal -- ============================================================================ -CREATE TABLE wholesale_settings ( +CREATE TABLE IF NOT EXISTS wholesale_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, require_approval BOOLEAN NOT NULL DEFAULT true, @@ -392,10 +470,20 @@ CREATE TABLE wholesale_settings ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER wholesale_settings_updated_at BEFORE UPDATE ON wholesale_settings +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'wholesale_settings_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER wholesale_settings_updated_at BEFORE UPDATE ON wholesale_settings FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE TABLE wholesale_customers ( +CREATE TABLE IF NOT EXISTS wholesale_customers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES neon_auth.user(id) ON DELETE SET NULL, brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, @@ -420,12 +508,22 @@ CREATE TABLE wholesale_customers ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER wholesale_customers_updated_at BEFORE UPDATE ON wholesale_customers +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'wholesale_customers_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER wholesale_customers_updated_at BEFORE UPDATE ON wholesale_customers FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE INDEX wholesale_customers_brand_idx ON wholesale_customers (brand_id); +CREATE INDEX IF NOT EXISTS wholesale_customers_brand_idx ON wholesale_customers (brand_id); -CREATE TABLE wholesale_products ( +CREATE TABLE IF NOT EXISTS wholesale_products ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, rc_product_id UUID REFERENCES products(id) ON DELETE SET NULL, @@ -454,12 +552,22 @@ CREATE TABLE wholesale_products ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER wholesale_products_updated_at BEFORE UPDATE ON wholesale_products +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'wholesale_products_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER wholesale_products_updated_at BEFORE UPDATE ON wholesale_products FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE INDEX wholesale_products_brand_idx ON wholesale_products (brand_id); +CREATE INDEX IF NOT EXISTS wholesale_products_brand_idx ON wholesale_products (brand_id); -CREATE TABLE wholesale_orders ( +CREATE TABLE IF NOT EXISTS wholesale_orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE RESTRICT, @@ -489,14 +597,24 @@ CREATE TABLE wholesale_orders ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER wholesale_orders_updated_at BEFORE UPDATE ON wholesale_orders +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'wholesale_orders_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER wholesale_orders_updated_at BEFORE UPDATE ON wholesale_orders FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE INDEX wholesale_orders_brand_idx ON wholesale_orders (brand_id); -CREATE INDEX wholesale_orders_customer_idx ON wholesale_orders (customer_id); -CREATE INDEX wholesale_orders_status_idx ON wholesale_orders (brand_id, status); +CREATE INDEX IF NOT EXISTS wholesale_orders_brand_idx ON wholesale_orders (brand_id); +CREATE INDEX IF NOT EXISTS wholesale_orders_customer_idx ON wholesale_orders (customer_id); +CREATE INDEX IF NOT EXISTS wholesale_orders_status_idx ON wholesale_orders (brand_id, status); -CREATE TABLE wholesale_order_items ( +CREATE TABLE IF NOT EXISTS wholesale_order_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), wholesale_order_id UUID NOT NULL REFERENCES wholesale_orders(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES wholesale_products(id) ON DELETE RESTRICT, @@ -506,9 +624,9 @@ CREATE TABLE wholesale_order_items ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX wholesale_order_items_order_idx ON wholesale_order_items (wholesale_order_id); +CREATE INDEX IF NOT EXISTS wholesale_order_items_order_idx ON wholesale_order_items (wholesale_order_id); -CREATE TABLE wholesale_deposits ( +CREATE TABLE IF NOT EXISTS wholesale_deposits ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), wholesale_order_id UUID NOT NULL REFERENCES wholesale_orders(id) ON DELETE CASCADE, amount NUMERIC(10,2) NOT NULL, @@ -518,7 +636,7 @@ CREATE TABLE wholesale_deposits ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TABLE wholesale_notifications ( +CREATE TABLE IF NOT EXISTS wholesale_notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE, @@ -535,9 +653,9 @@ CREATE TABLE wholesale_notifications ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX wholesale_notifications_brand_idx ON wholesale_notifications (brand_id); +CREATE INDEX IF NOT EXISTS wholesale_notifications_brand_idx ON wholesale_notifications (brand_id); -CREATE TABLE wholesale_customer_product_pricing ( +CREATE TABLE IF NOT EXISTS wholesale_customer_product_pricing ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES wholesale_products(id) ON DELETE CASCADE, @@ -547,7 +665,7 @@ CREATE TABLE wholesale_customer_product_pricing ( UNIQUE (customer_id, product_id) ); -CREATE TABLE wholesale_webhook_settings ( +CREATE TABLE IF NOT EXISTS wholesale_webhook_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, url TEXT NOT NULL, @@ -557,10 +675,20 @@ CREATE TABLE wholesale_webhook_settings ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER wholesale_webhook_settings_updated_at BEFORE UPDATE ON wholesale_webhook_settings +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'wholesale_webhook_settings_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER wholesale_webhook_settings_updated_at BEFORE UPDATE ON wholesale_webhook_settings FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE TABLE wholesale_sync_log ( +CREATE TABLE IF NOT EXISTS wholesale_sync_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, event_type TEXT NOT NULL, @@ -574,13 +702,13 @@ CREATE TABLE wholesale_sync_log ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX wholesale_sync_log_brand_idx ON wholesale_sync_log (brand_id); +CREATE INDEX IF NOT EXISTS wholesale_sync_log_brand_idx ON wholesale_sync_log (brand_id); -- ============================================================================ -- 10. Water Log -- ============================================================================ -CREATE TABLE water_headgates ( +CREATE TABLE IF NOT EXISTS water_headgates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, name TEXT NOT NULL, @@ -588,7 +716,7 @@ CREATE TABLE water_headgates ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TABLE water_irrigators ( +CREATE TABLE IF NOT EXISTS water_irrigators ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, name TEXT NOT NULL, @@ -599,14 +727,14 @@ CREATE TABLE water_irrigators ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TABLE water_sessions ( +CREATE TABLE IF NOT EXISTS water_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), irrigator_id UUID NOT NULL REFERENCES water_irrigators(id) ON DELETE CASCADE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TABLE water_log_entries ( +CREATE TABLE IF NOT EXISTS water_log_entries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, headgate_id UUID NOT NULL REFERENCES water_headgates(id) ON DELETE CASCADE, @@ -619,10 +747,10 @@ CREATE TABLE water_log_entries ( logged_by UUID REFERENCES admin_users(id) ); -CREATE INDEX water_log_entries_brand_idx ON water_log_entries (brand_id); -CREATE INDEX water_log_entries_headgate_idx ON water_log_entries (headgate_id); +CREATE INDEX IF NOT EXISTS water_log_entries_brand_idx ON water_log_entries (brand_id); +CREATE INDEX IF NOT EXISTS water_log_entries_headgate_idx ON water_log_entries (headgate_id); -CREATE TABLE water_alert_log ( +CREATE TABLE IF NOT EXISTS water_alert_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, alert_type TEXT NOT NULL, @@ -637,7 +765,7 @@ CREATE TABLE water_alert_log ( -- 11. Communications (Harvest Reach) -- ============================================================================ -CREATE TABLE communication_settings ( +CREATE TABLE IF NOT EXISTS communication_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, default_sender_email TEXT, @@ -649,10 +777,20 @@ CREATE TABLE communication_settings ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER communication_settings_updated_at BEFORE UPDATE ON communication_settings +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'communication_settings_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER communication_settings_updated_at BEFORE UPDATE ON communication_settings FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE TABLE communication_templates ( +CREATE TABLE IF NOT EXISTS communication_templates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, name TEXT NOT NULL, @@ -666,12 +804,22 @@ CREATE TABLE communication_templates ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER communication_templates_updated_at BEFORE UPDATE ON communication_templates +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'communication_templates_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER communication_templates_updated_at BEFORE UPDATE ON communication_templates FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE INDEX communication_templates_brand_idx ON communication_templates (brand_id); +CREATE INDEX IF NOT EXISTS communication_templates_brand_idx ON communication_templates (brand_id); -CREATE TABLE communication_segments ( +CREATE TABLE IF NOT EXISTS communication_segments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, name TEXT NOT NULL, @@ -682,9 +830,9 @@ CREATE TABLE communication_segments ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX communication_segments_brand_idx ON communication_segments (brand_id); +CREATE INDEX IF NOT EXISTS communication_segments_brand_idx ON communication_segments (brand_id); -CREATE TABLE communication_campaigns ( +CREATE TABLE IF NOT EXISTS communication_campaigns ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, name TEXT NOT NULL, @@ -705,13 +853,23 @@ CREATE TABLE communication_campaigns ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER communication_campaigns_updated_at BEFORE UPDATE ON communication_campaigns +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'communication_campaigns_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER communication_campaigns_updated_at BEFORE UPDATE ON communication_campaigns FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE INDEX communication_campaigns_brand_idx ON communication_campaigns (brand_id); -CREATE INDEX communication_campaigns_status_idx ON communication_campaigns (brand_id, status); +CREATE INDEX IF NOT EXISTS communication_campaigns_brand_idx ON communication_campaigns (brand_id); +CREATE INDEX IF NOT EXISTS communication_campaigns_status_idx ON communication_campaigns (brand_id, status); -CREATE TABLE communication_contacts ( +CREATE TABLE IF NOT EXISTS communication_contacts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, email TEXT, @@ -734,13 +892,23 @@ CREATE TABLE communication_contacts ( CONSTRAINT chk_has_email_or_phone CHECK (email IS NOT NULL OR phone IS NOT NULL) ); -CREATE TRIGGER communication_contacts_updated_at BEFORE UPDATE ON communication_contacts +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'communication_contacts_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER communication_contacts_updated_at BEFORE UPDATE ON communication_contacts FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE INDEX communication_contacts_brand_idx ON communication_contacts (brand_id); -CREATE INDEX communication_contacts_email_idx ON communication_contacts (brand_id, email); +CREATE INDEX IF NOT EXISTS communication_contacts_brand_idx ON communication_contacts (brand_id); +CREATE INDEX IF NOT EXISTS communication_contacts_email_idx ON communication_contacts (brand_id, email); -CREATE TABLE communication_message_logs ( +CREATE TABLE IF NOT EXISTS communication_message_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, campaign_id UUID REFERENCES communication_campaigns(id) ON DELETE SET NULL, @@ -757,10 +925,10 @@ CREATE TABLE communication_message_logs ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX communication_message_logs_brand_idx ON communication_message_logs (brand_id); -CREATE INDEX communication_message_logs_campaign_idx ON communication_message_logs (campaign_id); +CREATE INDEX IF NOT EXISTS communication_message_logs_brand_idx ON communication_message_logs (brand_id); +CREATE INDEX IF NOT EXISTS communication_message_logs_campaign_idx ON communication_message_logs (campaign_id); -CREATE TABLE customer_communication_preferences ( +CREATE TABLE IF NOT EXISTS customer_communication_preferences ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), customer_id UUID NOT NULL, brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, @@ -773,11 +941,21 @@ CREATE TABLE customer_communication_preferences ( UNIQUE (customer_id, brand_id) ); -CREATE TRIGGER customer_communication_preferences_updated_at BEFORE UPDATE ON customer_communication_preferences +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'customer_communication_preferences_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER customer_communication_preferences_updated_at BEFORE UPDATE ON customer_communication_preferences FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -- Email automation sequences -CREATE TABLE abandoned_cart_recovery ( +CREATE TABLE IF NOT EXISTS abandoned_cart_recovery ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, customer_id UUID REFERENCES wholesale_customers(id) ON DELETE SET NULL, @@ -800,10 +978,20 @@ CREATE TABLE abandoned_cart_recovery ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER abandoned_cart_recovery_updated_at BEFORE UPDATE ON abandoned_cart_recovery +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'abandoned_cart_recovery_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER abandoned_cart_recovery_updated_at BEFORE UPDATE ON abandoned_cart_recovery FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE TABLE welcome_email_sequence ( +CREATE TABLE IF NOT EXISTS welcome_email_sequence ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, contact_id UUID REFERENCES communication_contacts(id) ON DELETE CASCADE, @@ -823,14 +1011,24 @@ CREATE TABLE welcome_email_sequence ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER welcome_email_sequence_updated_at BEFORE UPDATE ON welcome_email_sequence +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'welcome_email_sequence_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER welcome_email_sequence_updated_at BEFORE UPDATE ON welcome_email_sequence FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -- ============================================================================ -- 12. Shipping + Payments -- ============================================================================ -CREATE TABLE shipping_settings ( +CREATE TABLE IF NOT EXISTS shipping_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, carrier TEXT NOT NULL DEFAULT 'fedex', @@ -845,10 +1043,20 @@ CREATE TABLE shipping_settings ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER shipping_settings_updated_at BEFORE UPDATE ON shipping_settings +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'shipping_settings_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER shipping_settings_updated_at BEFORE UPDATE ON shipping_settings FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE TABLE shipments ( +CREATE TABLE IF NOT EXISTS shipments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, carrier TEXT NOT NULL DEFAULT 'fedex', @@ -868,12 +1076,22 @@ CREATE TABLE shipments ( created_by UUID REFERENCES admin_users(id) ); -CREATE TRIGGER shipments_updated_at BEFORE UPDATE ON shipments +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'shipments_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER shipments_updated_at BEFORE UPDATE ON shipments FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE INDEX shipments_order_idx ON shipments (order_id); +CREATE INDEX IF NOT EXISTS shipments_order_idx ON shipments (order_id); -CREATE TABLE payment_settings ( +CREATE TABLE IF NOT EXISTS payment_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, provider TEXT, @@ -885,14 +1103,24 @@ CREATE TABLE payment_settings ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER payment_settings_updated_at BEFORE UPDATE ON payment_settings +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'payment_settings_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER payment_settings_updated_at BEFORE UPDATE ON payment_settings FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -- ============================================================================ -- 13. Square Sync -- ============================================================================ -CREATE TABLE square_sync_queue ( +CREATE TABLE IF NOT EXISTS square_sync_queue ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, sync_type TEXT NOT NULL DEFAULT 'products' @@ -905,9 +1133,9 @@ CREATE TABLE square_sync_queue ( last_error TEXT ); -CREATE INDEX square_sync_queue_brand_idx ON square_sync_queue (brand_id, status); +CREATE INDEX IF NOT EXISTS square_sync_queue_brand_idx ON square_sync_queue (brand_id, status); -CREATE TABLE square_sync_log ( +CREATE TABLE IF NOT EXISTS square_sync_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, sync_type TEXT NOT NULL, @@ -918,13 +1146,13 @@ CREATE TABLE square_sync_log ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX square_sync_log_brand_idx ON square_sync_log (brand_id); +CREATE INDEX IF NOT EXISTS square_sync_log_brand_idx ON square_sync_log (brand_id); -- ============================================================================ -- 14. Time Tracking -- ============================================================================ -CREATE TABLE time_tracking_settings ( +CREATE TABLE IF NOT EXISTS time_tracking_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, pay_period_start_day INTEGER NOT NULL DEFAULT 0, @@ -937,10 +1165,20 @@ CREATE TABLE time_tracking_settings ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER time_tracking_settings_updated_at BEFORE UPDATE ON time_tracking_settings +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'time_tracking_settings_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER time_tracking_settings_updated_at BEFORE UPDATE ON time_tracking_settings FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE TABLE time_tracking_workers ( +CREATE TABLE IF NOT EXISTS time_tracking_workers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, name TEXT NOT NULL, @@ -953,9 +1191,9 @@ CREATE TABLE time_tracking_workers ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX time_tracking_workers_brand_idx ON time_tracking_workers (brand_id); +CREATE INDEX IF NOT EXISTS time_tracking_workers_brand_idx ON time_tracking_workers (brand_id); -CREATE TABLE time_tracking_tasks ( +CREATE TABLE IF NOT EXISTS time_tracking_tasks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, name TEXT NOT NULL, @@ -967,9 +1205,9 @@ CREATE TABLE time_tracking_tasks ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX time_tracking_tasks_brand_idx ON time_tracking_tasks (brand_id); +CREATE INDEX IF NOT EXISTS time_tracking_tasks_brand_idx ON time_tracking_tasks (brand_id); -CREATE TABLE time_tracking_logs ( +CREATE TABLE IF NOT EXISTS time_tracking_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, worker_id UUID NOT NULL REFERENCES time_tracking_workers(id) ON DELETE CASCADE, @@ -984,11 +1222,11 @@ CREATE TABLE time_tracking_logs ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX time_tracking_logs_brand_idx ON time_tracking_logs (brand_id); -CREATE INDEX time_tracking_logs_worker_idx ON time_tracking_logs (worker_id); -CREATE INDEX time_tracking_logs_clock_in_idx ON time_tracking_logs (clock_in); +CREATE INDEX IF NOT EXISTS time_tracking_logs_brand_idx ON time_tracking_logs (brand_id); +CREATE INDEX IF NOT EXISTS time_tracking_logs_worker_idx ON time_tracking_logs (worker_id); +CREATE INDEX IF NOT EXISTS time_tracking_logs_clock_in_idx ON time_tracking_logs (clock_in); -CREATE TABLE time_tracking_notification_log ( +CREATE TABLE IF NOT EXISTS time_tracking_notification_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, worker_id UUID REFERENCES time_tracking_workers(id) ON DELETE SET NULL, @@ -1006,7 +1244,7 @@ CREATE TABLE time_tracking_notification_log ( -- 15. Operational + Audit -- ============================================================================ -CREATE TABLE operational_events ( +CREATE TABLE IF NOT EXISTS operational_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, event_type TEXT NOT NULL, @@ -1019,10 +1257,10 @@ CREATE TABLE operational_events ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX operational_events_brand_idx ON operational_events (brand_id); -CREATE INDEX operational_events_type_idx ON operational_events (brand_id, event_type); +CREATE INDEX IF NOT EXISTS operational_events_brand_idx ON operational_events (brand_id); +CREATE INDEX IF NOT EXISTS operational_events_type_idx ON operational_events (brand_id, event_type); -CREATE TABLE audit_logs ( +CREATE TABLE IF NOT EXISTS audit_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID, brand_id UUID REFERENCES brands(id) ON DELETE SET NULL, @@ -1034,11 +1272,11 @@ CREATE TABLE audit_logs ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX audit_logs_brand_idx ON audit_logs (brand_id); -CREATE INDEX audit_logs_user_idx ON audit_logs (user_id); -CREATE INDEX audit_logs_created_idx ON audit_logs (created_at DESC); +CREATE INDEX IF NOT EXISTS audit_logs_brand_idx ON audit_logs (brand_id); +CREATE INDEX IF NOT EXISTS audit_logs_user_idx ON audit_logs (user_id); +CREATE INDEX IF NOT EXISTS audit_logs_created_idx ON audit_logs (created_at DESC); -CREATE TABLE admin_action_logs ( +CREATE TABLE IF NOT EXISTS admin_action_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), admin_user_id UUID NOT NULL REFERENCES admin_users(id), brand_id UUID REFERENCES brands(id) ON DELETE SET NULL, @@ -1050,14 +1288,14 @@ CREATE TABLE admin_action_logs ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX admin_action_logs_admin_idx ON admin_action_logs (admin_user_id); -CREATE INDEX admin_action_logs_created_idx ON admin_action_logs (created_at DESC); +CREATE INDEX IF NOT EXISTS admin_action_logs_admin_idx ON admin_action_logs (admin_user_id); +CREATE INDEX IF NOT EXISTS admin_action_logs_created_idx ON admin_action_logs (created_at DESC); -- ============================================================================ -- 16. Support Tables -- ============================================================================ -CREATE TABLE user_carts ( +CREATE TABLE IF NOT EXISTS user_carts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE, @@ -1066,12 +1304,22 @@ CREATE TABLE user_carts ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TRIGGER user_carts_updated_at BEFORE UPDATE ON user_carts +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'user_carts_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER user_carts_updated_at BEFORE UPDATE ON user_carts FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; -CREATE UNIQUE INDEX user_carts_customer_idx ON user_carts (brand_id, customer_id); +CREATE UNIQUE INDEX IF NOT EXISTS user_carts_customer_idx ON user_carts (brand_id, customer_id); -CREATE TABLE referral_codes ( +CREATE TABLE IF NOT EXISTS referral_codes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, referrer_user_id UUID NOT NULL, @@ -1087,7 +1335,7 @@ CREATE TABLE referral_codes ( metadata JSONB DEFAULT '{}' ); -CREATE TABLE referral_redemptions ( +CREATE TABLE IF NOT EXISTS referral_redemptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), referral_code_id UUID REFERENCES referral_codes(id) ON DELETE CASCADE, brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, @@ -1102,7 +1350,7 @@ CREATE TABLE referral_redemptions ( metadata JSONB DEFAULT '{}' ); -CREATE TABLE changelogs ( +CREATE TABLE IF NOT EXISTS changelogs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, version VARCHAR(50) NOT NULL, @@ -1116,7 +1364,7 @@ CREATE TABLE changelogs ( UNIQUE (brand_id, version) ); -CREATE TABLE changelog_reads ( +CREATE TABLE IF NOT EXISTS changelog_reads ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, changelog_id UUID REFERENCES changelogs(id) ON DELETE CASCADE, @@ -1125,7 +1373,7 @@ CREATE TABLE changelog_reads ( UNIQUE (user_id, changelog_id) ); -CREATE TABLE onboarding_progress ( +CREATE TABLE IF NOT EXISTS onboarding_progress ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, user_id UUID NOT NULL, @@ -1140,7 +1388,7 @@ CREATE TABLE onboarding_progress ( UNIQUE (brand_id, user_id) ); -CREATE TABLE api_keys ( +CREATE TABLE IF NOT EXISTS api_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, @@ -1155,9 +1403,9 @@ CREATE TABLE api_keys ( created_by UUID NOT NULL ); -CREATE INDEX api_keys_brand_idx ON api_keys (brand_id); +CREATE INDEX IF NOT EXISTS api_keys_brand_idx ON api_keys (brand_id); -CREATE TABLE notification_preferences ( +CREATE TABLE IF NOT EXISTS notification_preferences ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, @@ -1181,7 +1429,7 @@ CREATE TABLE notification_preferences ( -- 17. Files + Audit Log -- ============================================================================ -CREATE TABLE files ( +CREATE TABLE IF NOT EXISTS files ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, storage_key TEXT NOT NULL UNIQUE, @@ -1191,9 +1439,9 @@ CREATE TABLE files ( uploaded_by UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX files_brand_idx ON files (brand_id); +CREATE INDEX IF NOT EXISTS files_brand_idx ON files (brand_id); -CREATE TABLE audit_log ( +CREATE TABLE IF NOT EXISTS audit_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, user_id UUID, @@ -1203,7 +1451,7 @@ CREATE TABLE audit_log ( payload JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX audit_log_brand_idx ON audit_log (brand_id, created_at); +CREATE INDEX IF NOT EXISTS audit_log_brand_idx ON audit_log (brand_id, created_at); -- ============================================================================ -- 18. GUC Helpers + RLS @@ -1804,5 +2052,4 @@ BEGIN VALUES (p_user_id, p_brand_id, p_action, p_entity_type, p_entity_id, p_details, p_ip_address); END; $$; - -COMMIT; +-- COMMIT; (transaction controlled by migrate runner) \ No newline at end of file diff --git a/db/migrations/0002_admin_password.sql b/db/migrations/0002_admin_password.sql index edeacdd..35b89ed 100644 --- a/db/migrations/0002_admin_password.sql +++ b/db/migrations/0002_admin_password.sql @@ -10,12 +10,11 @@ -- `src/lib/auth.ts` — it queries this column and runs `verifyPassword` -- (see `src/lib/passwords.ts`) before returning the user. -BEGIN; +-- Transaction is managed by the migrate runner (scripts/migrate.js). +-- File kept small and re-runnable via IF NOT EXISTS on the ALTER. ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT; -- Update the updated_at trigger tracking — no new triggers needed since -- `users` already has `set_updated_at` from migration 0001. - -COMMIT; diff --git a/scripts/migrate.js b/scripts/migrate.js index 5a08099..174d392 100644 --- a/scripts/migrate.js +++ b/scripts/migrate.js @@ -1,14 +1,16 @@ #!/usr/bin/env node /** * Apply Postgres migrations from `db/migrations/*.sql` in lexical order. - * Wraps the whole thing in a transaction; tracks applied files in - * `_migrations` so re-runs are safe. + * Wraps each new file in a transaction; tracks applied files in + * `_migrations` so re-runs are safe and idempotent. + * + * The 0001_init.sql (and 0002) are now fully re-runnable (IF NOT EXISTS + + * trigger guards) + this script has repair logic for DBs that were + * initialized before tracking was introduced. * * Usage: - * npm run db:migrate - * - * Replaces the old `supabase/push-migrations.js` — that script was - * hardcoded to a Supabase URL. This one reads `DATABASE_URL` directly. + * npm run migrate + * npm run migrate:one # same as above (applies all pending) */ require("dotenv").config({ path: ".env.local" }); @@ -51,6 +53,37 @@ async function main() { ); const appliedSet = new Set(applied.map((r) => r.filename)); + // Repair for historical DBs (applied before _migrations tracking existed, + // or via direct psql / earlier tooling). If the core objects are present + // we record the filename so future deploys (and server-side recovery runs) + // treat 0001/0002 as done without re-executing the large init script. + async function ensureTracked(filename, existenceCheckSql) { + if (appliedSet.has(filename)) return; + try { + const { rows } = await client.query(existenceCheckSql); + if (rows.length > 0) { + await client.query( + `INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT (filename) DO NOTHING`, + [filename] + ); + console.log(`✓ ${filename} (objects present in DB; repaired tracking)`); + appliedSet.add(filename); + } + } catch (e) { + // Non-fatal: the check may fail on a brand-new DB or with limited perms. + // We'll let the normal apply path handle it. + } + } + + await ensureTracked( + "0001_init.sql", + "SELECT 1 FROM information_schema.tables WHERE table_name = 'admin_users' LIMIT 1" + ); + await ensureTracked( + "0002_admin_password.sql", + "SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'password_hash' LIMIT 1" + ); + let appliedNow = 0; for (const file of files) { if (appliedSet.has(file)) {