From 49b8e2721910be8c6919f3ef97fef985ca934006 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 25 Jun 2026 17:48:32 -0600 Subject: [PATCH] =?UTF-8?q?chore(supabase):=20full=20purge=20=E2=80=94=20r?= =?UTF-8?q?emove=20all=20Supabase=20references=20from=20codebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete supabase/ directory (config.toml, push-migrations.js, ADMIN_CREATE_STOP_FIX.sql, 137 archived migration files) - Remove @supabase/ssr and @supabase/supabase-js from package.json - Strip *.supabase.co from next.config.ts image hostnames - Strip https://*.supabase.co from vercel.json CSP connect-src - Remove 'supabase/**' ignore from eslint.config.mjs - Clean supabase references from src/lib/db.ts, vitest.config.ts, db/seeds/2026-tuxedo-tour-stops.sql, src/actions/storefront.ts, scripts/import-tuxedo-stops.ts comments - Rewrite env-var docs in README, ENVIRONMENT, PRODUCTION_SETUP, PRODUCTION_DEPLOYMENT_CHECKLIST, LAUNCH_CHECKLIST, CLAUDE, MEMORY, REPORT to drop NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / supabase link / supabase CLI references The canonical migration runner is now scripts/migrate.js (uses pg directly via DATABASE_URL). Migrations live in db/migrations/. The Supabase CLI is no longer in the codebase. The only remaining '@supabase/*' in the dep tree is @supabase/auth-js as a transitive of @neondatabase/auth (Neon Auth / Better Auth). Final source scan: grep -rln '@supabase\|rest/v1\|supabase\.co' src/ tests/ db/ scripts/ next.config.ts vercel.json eslint.config.mjs package.json returns zero. Verification: npm install clean (11 added, 21 removed, 12 changed), tsc shows same 17 pre-existing errors (Stripe dahlia API version + fetch preconnect mocks), vitest 172/175 (3 pre-existing failures in getAdminUser.test.ts), lint shows same 14 pre-existing errors. Live-tested: dev server boots in 248ms, public storefronts (/) (/login) (/pricing) (/tuxedo) (/indian-river-direct) all return 200; /admin/v2?demo=1 reaches 200 after dev_session redirect; storefront renders real brand content. --- CLAUDE.md | 16 +- ENVIRONMENT.md | 24 +- LAUNCH_CHECKLIST.md | 14 +- MEMORY.md | 89 +- PRODUCTION_DEPLOYMENT_CHECKLIST.md | 45 +- PRODUCTION_SETUP.md | 28 +- README.md | 17 +- REPORT.md | 13 +- db/seeds/2026-tuxedo-tour-stops.sql | 1 - docs/water-log.md | 2 +- eslint.config.mjs | 3 +- next.config.ts | 15 +- package.json | 2 - scripts/import-tuxedo-stops.ts | 3 +- src/actions/storefront.ts | 11 +- src/lib/db.ts | 6 +- supabase/.gitignore | 8 - supabase/ADMIN_CREATE_STOP_FIX.sql | 92 - supabase/config.toml | 408 -- .../.archived/001_checkout_hardening.sql | 201 - .../002_remove_broad_anon_select.sql | 10 - .../migrations/.archived/003_audit_logs.sql | 103 - .../.archived/004_admin_order_fetch.sql | 193 - .../migrations/.archived/005_water_log.sql | 342 -- .../.archived/006_water_log_rpcs_fixed.sql | 263 - .../.archived/007_water_log_column_fix.sql | 232 - .../.archived/008_water_log_soft_delete.sql | 123 - .../009_water_log_entry_edit_delete.sql | 88 - .../.archived/010_water_log_unit_types.sql | 202 - .../011_water_log_consolidated_fix.sql | 289 - .../012_water_log_display_summary.sql | 103 - .../.archived/013_water_log_seed_admin.sql | 13 - .../.archived/014_water_sessions_rls.sql | 9 - .../015_water_users_security_hardening.sql | 90 - .../.archived/016_communication_center.sql | 662 --- .../.archived/017_communication_contacts.sql | 878 --- .../.archived/018_contact_import_metadata.sql | 230 - .../.archived/019_customers_table.sql | 75 - .../.archived/020_order_customer_linking.sql | 226 - .../.archived/021_shipping_only_brand.sql | 319 -- .../.archived/022_operational_events.sql | 646 --- .../.archived/023_cart_availability_check.sql | 56 - .../024_stop_product_assignment_rpcs.sql | 146 - .../.archived/025_fix_assignment_rpc_auth.sql | 177 - .../.archived/026_debug_assignment_rpc.sql | 101 - .../027_fix_rpc_signature_conflict.sql | 185 - .../.archived/028_fix_caller_uid_type.sql | 243 - .../.archived/029_drop_stale_overloads.sql | 261 - .../030_normalize_dev_user_caller_uid.sql | 268 - .../.archived/031_reports_v1_rpcs.sql | 337 -- .../.archived/032_store_employee_role.sql | 177 - .../.archived/033_admin_users_crud.sql | 384 -- .../.archived/034_password_change_flow.sql | 23 - .../.archived/035_admin_action_logs.sql | 106 - .../.archived/036_user_activity_logs.sql | 36 - .../037_update_admin_user_profile.sql | 334 -- .../.archived/038_product_images.sql | 31 - .../.archived/039_shipping_fulfillment.sql | 8 - .../040_shipping_fulfillment_rpcs.sql | 86 - .../.archived/041_payment_settings.sql | 105 - .../.archived/042_product_import_rpc.sql | 130 - .../.archived/043_square_sync_settings.sql | 10 - .../.archived/044_square_sync_log.sql | 63 - .../045_extend_payment_settings_rpcs.sql | 53 - .../.archived/046_wholesale_portal.sql | 748 --- .../.archived/047_wholesale_registration.sql | 201 - .../.archived/048_wholesale_enabled.sql | 62 - .../049_wholesale_invoice_settings.sql | 211 - .../050_wholesale_online_payment.sql | 78 - .../051_wholesale_customer_pricing.sql | 92 - .../.archived/052_wholesale_credit_limit.sql | 124 - .../.archived/053_wholesale_bulk_actions.sql | 93 - .../054_wholesale_email_notifications.sql | 160 - .../055_wholesale_notification_settings.sql | 154 - .../.archived/056_wholesale_price_sheet.sql | 10 - .../.archived/057_notification_recipients.sql | 123 - .../058_fix_get_wholesale_orders.sql | 162 - .../.archived/059_order_status_delete.sql | 63 - .../.archived/060_wholesale_pickup_orders.sql | 68 - .../migrations/.archived/061_soft_delete.sql | 167 - .../.archived/062_wholesale_webhooks.sql | 195 - .../063_auto_create_wholesale_settings.sql | 67 - .../.archived/064_seed_wholesale_settings.sql | 163 - .../065_fix_get_wholesale_products.sql | 43 - .../.archived/066_auto_square_sync.sql | 146 - .../.archived/067_checkout_session_id.sql | 160 - .../migrations/.archived/068_user_carts.sql | 67 - .../.archived/069_brand_scoping_phase2.sql | 606 --- .../.archived/070_rls_policy_audit.sql | 278 - .../071_wholesale_square_sync_toggle.sql | 180 - .../.archived/072_orders_brand_scoping.sql | 218 - .../.archived/073_pickup_order_items_rpc.sql | 185 - .../.archived/074_products_soft_delete.sql | 77 - .../.archived/075_stops_soft_delete.sql | 82 - .../.archived/076_stops_address_cutoff.sql | 6 - .../migrations/.archived/077_stops_status.sql | 9 - .../.archived/078_wholesale_deposit_guard.sql | 63 - .../.archived/079_resend_analytics.sql | 22 - .../.archived/080_communication_segments.sql | 89 - .../.archived/081_stop_blast_campaign.sql | 135 - .../082_brand_name_in_campaign_email.sql | 74 - .../.archived/083_shipping_settings.sql | 99 - .../.archived/083_stripe_connect.sql | 140 - .../migrations/.archived/084_shipments.sql | 73 - .../.archived/085_brand_settings.sql | 154 - .../086_brand_settings_email_integration.sql | 330 -- .../.archived/087_brand_logos_bucket.sql | 77 - .../.archived/088_brand_features.sql | 64 - .../089_ai_providers_custom_integrations.sql | 134 - .../.archived/090_storefront_settings.sql | 55 - .../.archived/091_brand_plan_tier.sql | 83 - .../.archived/092_stripe_subscriptions.sql | 74 - .../.archived/093_add_hero_image.sql | 170 - .../094_brand_color_customization.sql | 197 - .../migrations/.archived/095_tax_settings.sql | 248 - .../.archived/096_tax_orders_index.sql | 6 - .../097_extend_create_order_with_items.sql | 192 - .../.archived/098_products_is_taxable.sql | 53 - .../.archived/099_contact_imports_bucket.sql | 36 - .../099_harvest_reach_segmentation.sql | 554 -- .../.archived/108_lot_quantity_tracking.sql | 77 - .../.archived/109_enable_rls_critical.sql | 160 - .../.archived/110_rls_zero_policy_tables.sql | 303 -- .../111_route_trace_recent_activity.sql | 96 - .../119_regenerate_headgate_token_brand.sql | 30 - .../migrations/.archived/120_water_alerts.sql | 269 - .../.archived/126_founder_pain_log.sql | 39 - .../127_platform_command_center_rpc.sql | 155 - ...get_water_headgates_admin_last_used_at.sql | 11 - .../.archived/129_worker_time_tracking.sql | 569 -- .../130_time_tracking_pay_period_overtime.sql | 186 - .../133_time_tracking_notifications.sql | 349 -- .../.archived/134_abandoned_cart_recovery.sql | 123 - .../.archived/135_email_automation_rpcs.sql | 226 - .../136_email_automation_missing_rpcs.sql | 257 - .../137_time_tracking_workers_and_tasks.sql | 193 - .../.archived/138_time_tracking_logs.sql | 70 - .../139_time_tracking_missing_rpcs.sql | 115 - .../140_time_tracking_field_rpcs.sql | 264 - .../141_update_user_password_rpc.sql | 20 - .../.archived/142_integration_credentials.sql | 85 - .../.archived/143_enable_route_trace.sql | 4 - .../144_time_tracking_worker_number_rls.sql | 116 - .../145_create_product_images_bucket.sql | 17 - .../.archived/146_sitemap_stops_rpc.sql | 32 - .../.archived/147_admin_create_stop_rpcs.sql | 244 - .../.archived/148_public_stops_rpc.sql | 49 - .../149_product_availability_dates.sql | 9 - .../.archived/200_production_features.sql | 258 - .../migrations/.archived/201_seed_data.sql | 23 - .../202_delete_stop_orders_guard_fix.sql | 70 - .../.archived/202_fix_admin_create_stop.sql | 145 - .../.archived/203_locations_table.sql | 470 -- .../203_plan_usage_active_products.sql | 82 - ...204_admin_users_email_and_auth_subject.sql | 214 - .../.archived/204_authjs_tables.sql | 73 - .../204_locations_public_read_policy.sql | 19 - .../.archived/205_admin_list_locations.sql | 57 - .../206_locations_rededupe_by_city.sql | 142 - .../.archived/207_multi_brand_admin.sql | 185 - .../.archived/208_stops_date_to_date.sql | 73 - .../209_authjs_auto_create_admin.sql | 95 - .../migrations/.archived/BUNDLE_018_042.sql | 4778 ----------------- .../migrations/.archived/XXX_blog_tables.sql | 77 - .../.archived/XXX_launch_checklist.sql | 22 - .../.archived/XXX_roadmap_tables.sql | 45 - .../.archived/XXX_waitlist_table.sql | 30 - .../migrations/.archived/_000_auth_schema.sql | 111 - supabase/push-migrations.js | 155 - vercel.json | 2 +- vitest.config.ts | 2 +- 171 files changed, 136 insertions(+), 28594 deletions(-) delete mode 100644 supabase/.gitignore delete mode 100644 supabase/ADMIN_CREATE_STOP_FIX.sql delete mode 100644 supabase/config.toml delete mode 100644 supabase/migrations/.archived/001_checkout_hardening.sql delete mode 100644 supabase/migrations/.archived/002_remove_broad_anon_select.sql delete mode 100644 supabase/migrations/.archived/003_audit_logs.sql delete mode 100644 supabase/migrations/.archived/004_admin_order_fetch.sql delete mode 100644 supabase/migrations/.archived/005_water_log.sql delete mode 100644 supabase/migrations/.archived/006_water_log_rpcs_fixed.sql delete mode 100644 supabase/migrations/.archived/007_water_log_column_fix.sql delete mode 100644 supabase/migrations/.archived/008_water_log_soft_delete.sql delete mode 100644 supabase/migrations/.archived/009_water_log_entry_edit_delete.sql delete mode 100644 supabase/migrations/.archived/010_water_log_unit_types.sql delete mode 100644 supabase/migrations/.archived/011_water_log_consolidated_fix.sql delete mode 100644 supabase/migrations/.archived/012_water_log_display_summary.sql delete mode 100644 supabase/migrations/.archived/013_water_log_seed_admin.sql delete mode 100644 supabase/migrations/.archived/014_water_sessions_rls.sql delete mode 100644 supabase/migrations/.archived/015_water_users_security_hardening.sql delete mode 100644 supabase/migrations/.archived/016_communication_center.sql delete mode 100644 supabase/migrations/.archived/017_communication_contacts.sql delete mode 100644 supabase/migrations/.archived/018_contact_import_metadata.sql delete mode 100644 supabase/migrations/.archived/019_customers_table.sql delete mode 100644 supabase/migrations/.archived/020_order_customer_linking.sql delete mode 100644 supabase/migrations/.archived/021_shipping_only_brand.sql delete mode 100644 supabase/migrations/.archived/022_operational_events.sql delete mode 100644 supabase/migrations/.archived/023_cart_availability_check.sql delete mode 100644 supabase/migrations/.archived/024_stop_product_assignment_rpcs.sql delete mode 100644 supabase/migrations/.archived/025_fix_assignment_rpc_auth.sql delete mode 100644 supabase/migrations/.archived/026_debug_assignment_rpc.sql delete mode 100644 supabase/migrations/.archived/027_fix_rpc_signature_conflict.sql delete mode 100644 supabase/migrations/.archived/028_fix_caller_uid_type.sql delete mode 100644 supabase/migrations/.archived/029_drop_stale_overloads.sql delete mode 100644 supabase/migrations/.archived/030_normalize_dev_user_caller_uid.sql delete mode 100644 supabase/migrations/.archived/031_reports_v1_rpcs.sql delete mode 100644 supabase/migrations/.archived/032_store_employee_role.sql delete mode 100644 supabase/migrations/.archived/033_admin_users_crud.sql delete mode 100644 supabase/migrations/.archived/034_password_change_flow.sql delete mode 100644 supabase/migrations/.archived/035_admin_action_logs.sql delete mode 100644 supabase/migrations/.archived/036_user_activity_logs.sql delete mode 100644 supabase/migrations/.archived/037_update_admin_user_profile.sql delete mode 100644 supabase/migrations/.archived/038_product_images.sql delete mode 100644 supabase/migrations/.archived/039_shipping_fulfillment.sql delete mode 100644 supabase/migrations/.archived/040_shipping_fulfillment_rpcs.sql delete mode 100644 supabase/migrations/.archived/041_payment_settings.sql delete mode 100644 supabase/migrations/.archived/042_product_import_rpc.sql delete mode 100644 supabase/migrations/.archived/043_square_sync_settings.sql delete mode 100644 supabase/migrations/.archived/044_square_sync_log.sql delete mode 100644 supabase/migrations/.archived/045_extend_payment_settings_rpcs.sql delete mode 100644 supabase/migrations/.archived/046_wholesale_portal.sql delete mode 100644 supabase/migrations/.archived/047_wholesale_registration.sql delete mode 100644 supabase/migrations/.archived/048_wholesale_enabled.sql delete mode 100644 supabase/migrations/.archived/049_wholesale_invoice_settings.sql delete mode 100644 supabase/migrations/.archived/050_wholesale_online_payment.sql delete mode 100644 supabase/migrations/.archived/051_wholesale_customer_pricing.sql delete mode 100644 supabase/migrations/.archived/052_wholesale_credit_limit.sql delete mode 100644 supabase/migrations/.archived/053_wholesale_bulk_actions.sql delete mode 100644 supabase/migrations/.archived/054_wholesale_email_notifications.sql delete mode 100644 supabase/migrations/.archived/055_wholesale_notification_settings.sql delete mode 100644 supabase/migrations/.archived/056_wholesale_price_sheet.sql delete mode 100644 supabase/migrations/.archived/057_notification_recipients.sql delete mode 100644 supabase/migrations/.archived/058_fix_get_wholesale_orders.sql delete mode 100644 supabase/migrations/.archived/059_order_status_delete.sql delete mode 100644 supabase/migrations/.archived/060_wholesale_pickup_orders.sql delete mode 100644 supabase/migrations/.archived/061_soft_delete.sql delete mode 100644 supabase/migrations/.archived/062_wholesale_webhooks.sql delete mode 100644 supabase/migrations/.archived/063_auto_create_wholesale_settings.sql delete mode 100644 supabase/migrations/.archived/064_seed_wholesale_settings.sql delete mode 100644 supabase/migrations/.archived/065_fix_get_wholesale_products.sql delete mode 100644 supabase/migrations/.archived/066_auto_square_sync.sql delete mode 100644 supabase/migrations/.archived/067_checkout_session_id.sql delete mode 100644 supabase/migrations/.archived/068_user_carts.sql delete mode 100644 supabase/migrations/.archived/069_brand_scoping_phase2.sql delete mode 100644 supabase/migrations/.archived/070_rls_policy_audit.sql delete mode 100644 supabase/migrations/.archived/071_wholesale_square_sync_toggle.sql delete mode 100644 supabase/migrations/.archived/072_orders_brand_scoping.sql delete mode 100644 supabase/migrations/.archived/073_pickup_order_items_rpc.sql delete mode 100644 supabase/migrations/.archived/074_products_soft_delete.sql delete mode 100644 supabase/migrations/.archived/075_stops_soft_delete.sql delete mode 100644 supabase/migrations/.archived/076_stops_address_cutoff.sql delete mode 100644 supabase/migrations/.archived/077_stops_status.sql delete mode 100644 supabase/migrations/.archived/078_wholesale_deposit_guard.sql delete mode 100644 supabase/migrations/.archived/079_resend_analytics.sql delete mode 100644 supabase/migrations/.archived/080_communication_segments.sql delete mode 100644 supabase/migrations/.archived/081_stop_blast_campaign.sql delete mode 100644 supabase/migrations/.archived/082_brand_name_in_campaign_email.sql delete mode 100644 supabase/migrations/.archived/083_shipping_settings.sql delete mode 100644 supabase/migrations/.archived/083_stripe_connect.sql delete mode 100644 supabase/migrations/.archived/084_shipments.sql delete mode 100644 supabase/migrations/.archived/085_brand_settings.sql delete mode 100644 supabase/migrations/.archived/086_brand_settings_email_integration.sql delete mode 100644 supabase/migrations/.archived/087_brand_logos_bucket.sql delete mode 100644 supabase/migrations/.archived/088_brand_features.sql delete mode 100644 supabase/migrations/.archived/089_ai_providers_custom_integrations.sql delete mode 100644 supabase/migrations/.archived/090_storefront_settings.sql delete mode 100644 supabase/migrations/.archived/091_brand_plan_tier.sql delete mode 100644 supabase/migrations/.archived/092_stripe_subscriptions.sql delete mode 100644 supabase/migrations/.archived/093_add_hero_image.sql delete mode 100644 supabase/migrations/.archived/094_brand_color_customization.sql delete mode 100644 supabase/migrations/.archived/095_tax_settings.sql delete mode 100644 supabase/migrations/.archived/096_tax_orders_index.sql delete mode 100644 supabase/migrations/.archived/097_extend_create_order_with_items.sql delete mode 100644 supabase/migrations/.archived/098_products_is_taxable.sql delete mode 100644 supabase/migrations/.archived/099_contact_imports_bucket.sql delete mode 100644 supabase/migrations/.archived/099_harvest_reach_segmentation.sql delete mode 100644 supabase/migrations/.archived/108_lot_quantity_tracking.sql delete mode 100644 supabase/migrations/.archived/109_enable_rls_critical.sql delete mode 100644 supabase/migrations/.archived/110_rls_zero_policy_tables.sql delete mode 100644 supabase/migrations/.archived/111_route_trace_recent_activity.sql delete mode 100644 supabase/migrations/.archived/119_regenerate_headgate_token_brand.sql delete mode 100644 supabase/migrations/.archived/120_water_alerts.sql delete mode 100644 supabase/migrations/.archived/126_founder_pain_log.sql delete mode 100644 supabase/migrations/.archived/127_platform_command_center_rpc.sql delete mode 100644 supabase/migrations/.archived/128_fix_get_water_headgates_admin_last_used_at.sql delete mode 100644 supabase/migrations/.archived/129_worker_time_tracking.sql delete mode 100644 supabase/migrations/.archived/130_time_tracking_pay_period_overtime.sql delete mode 100644 supabase/migrations/.archived/133_time_tracking_notifications.sql delete mode 100644 supabase/migrations/.archived/134_abandoned_cart_recovery.sql delete mode 100644 supabase/migrations/.archived/135_email_automation_rpcs.sql delete mode 100644 supabase/migrations/.archived/136_email_automation_missing_rpcs.sql delete mode 100644 supabase/migrations/.archived/137_time_tracking_workers_and_tasks.sql delete mode 100644 supabase/migrations/.archived/138_time_tracking_logs.sql delete mode 100644 supabase/migrations/.archived/139_time_tracking_missing_rpcs.sql delete mode 100644 supabase/migrations/.archived/140_time_tracking_field_rpcs.sql delete mode 100644 supabase/migrations/.archived/141_update_user_password_rpc.sql delete mode 100644 supabase/migrations/.archived/142_integration_credentials.sql delete mode 100644 supabase/migrations/.archived/143_enable_route_trace.sql delete mode 100644 supabase/migrations/.archived/144_time_tracking_worker_number_rls.sql delete mode 100644 supabase/migrations/.archived/145_create_product_images_bucket.sql delete mode 100644 supabase/migrations/.archived/146_sitemap_stops_rpc.sql delete mode 100644 supabase/migrations/.archived/147_admin_create_stop_rpcs.sql delete mode 100644 supabase/migrations/.archived/148_public_stops_rpc.sql delete mode 100644 supabase/migrations/.archived/149_product_availability_dates.sql delete mode 100644 supabase/migrations/.archived/200_production_features.sql delete mode 100644 supabase/migrations/.archived/201_seed_data.sql delete mode 100644 supabase/migrations/.archived/202_delete_stop_orders_guard_fix.sql delete mode 100644 supabase/migrations/.archived/202_fix_admin_create_stop.sql delete mode 100644 supabase/migrations/.archived/203_locations_table.sql delete mode 100644 supabase/migrations/.archived/203_plan_usage_active_products.sql delete mode 100644 supabase/migrations/.archived/204_admin_users_email_and_auth_subject.sql delete mode 100644 supabase/migrations/.archived/204_authjs_tables.sql delete mode 100644 supabase/migrations/.archived/204_locations_public_read_policy.sql delete mode 100644 supabase/migrations/.archived/205_admin_list_locations.sql delete mode 100644 supabase/migrations/.archived/206_locations_rededupe_by_city.sql delete mode 100644 supabase/migrations/.archived/207_multi_brand_admin.sql delete mode 100644 supabase/migrations/.archived/208_stops_date_to_date.sql delete mode 100644 supabase/migrations/.archived/209_authjs_auto_create_admin.sql delete mode 100644 supabase/migrations/.archived/BUNDLE_018_042.sql delete mode 100644 supabase/migrations/.archived/XXX_blog_tables.sql delete mode 100644 supabase/migrations/.archived/XXX_launch_checklist.sql delete mode 100644 supabase/migrations/.archived/XXX_roadmap_tables.sql delete mode 100644 supabase/migrations/.archived/XXX_waitlist_table.sql delete mode 100644 supabase/migrations/.archived/_000_auth_schema.sql delete mode 100644 supabase/push-migrations.js diff --git a/CLAUDE.md b/CLAUDE.md index f59d3ac..6561bb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce whole Tech stack: Next.js 16 (App Router) · **Postgres** (direct) · **Neon Auth** (Better Auth) · Stripe · Square · Resend (email) · Tailwind CSS v4 -> **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST. +> **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code must connect to Postgres directly via the shared `pg` `Pool` from `src/lib/db.ts`. No Supabase JS client, REST gateway, or `*.supabase.co` calls anywhere in the codebase. --- @@ -33,10 +33,10 @@ npx tsc --noEmit # TypeScript check (no emit) npx playwright test # Run E2E tests (Playwright) ``` -> The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies. +> The migrate script is `scripts/migrate.js`. It reads `DATABASE_URL` (or `DATABASE_ADMIN_URL`) from `.env.local` via `dotenv` and applies any `db/migrations/*.sql` files not already recorded in `_migrations`. `pg` is already in devDependencies. > If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first. -**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details. +**Historical migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for the modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed). Cat `MEMORY.md` for details. E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config. @@ -93,13 +93,13 @@ Server actions are "use server" files that export async functions. Client compon ### Database (Postgres, direct) -The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs. +The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver to call `SECURITY DEFINER` PL/pgSQL functions; Drizzle (on top of the same pool) handles typed reads and the per-request `app.current_brand_id` GUC. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs. #### Connection - `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var. -- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names. -- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase. +- A single shared `pg` `Pool` is exported from `src/lib/db.ts` as `pool` (proxy) and `getPool()` (lazy). Server actions and API routes import it and call `pool.query(...)` against RPC names. The Drizzle client lives at `db/client.ts` and shares the same pool. +- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — none exist anywhere in the codebase. #### First production deploy / new prod DB bootstrap (critical for admin access) @@ -255,7 +255,7 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S ### Communications Module ("Harvest Reach") -The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.) +The communications system (`/admin/communications`) uses a separate set of tables that have **no row-level policies** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. Scoping is enforced by RPC + app layer. `send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`. @@ -282,7 +282,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act ## Key Conventions -- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`. +- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports, REST fetches, or `*.supabase.co` calls anywhere in the codebase. - `gen_random_uuid()` used in migrations for primary keys - Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE` - Status enums stored as TEXT — no PostgreSQL ENUM type diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index e6cf707..b0da3d9 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -10,15 +10,6 @@ Copy `.env.example` → `.env.local` and fill in the values. These are safe to commit and can be used in client bundles. Prefix with `NEXT_PUBLIC_`. -### `NEXT_PUBLIC_SUPABASE_URL` -Your Supabase project URL. -- **Where to get:** Supabase Dashboard → Project Settings → API → Project URL -- **Example:** `https://abc123.supabase.co` - -### `NEXT_PUBLIC_SUPABASE_ANON_KEY` -Supabase anonymous (anon) key — safe for client-side use. -- **Where to get:** Supabase Dashboard → Project Settings → API → anon key - ### `NEXT_PUBLIC_BASE_URL` The base URL of your deployment. Used for OAuth redirects and webhook URLs. - **Local:** `http://localhost:3000` @@ -40,12 +31,16 @@ Set to a brand slug to hide other brand routes in single-brand mode. **Never prefix with `NEXT_PUBLIC_`**. These must only be accessed in Server Components, Server Actions, or API Routes. -### Supabase +### Database -#### `SUPABASE_SERVICE_ROLE_KEY` -Full service role key — grants DB admin access bypassing RLS. -- **Where to get:** Supabase Dashboard → Project Settings → API → service_role key -- **⚠️ Critical:** Never expose this to the client. Used only in server-side code. +#### `DATABASE_URL` +Full Postgres connection string. The app uses this exclusively (no Supabase, no JS client) for every server-side query — server actions and API routes import a shared `pg` `Pool` from `src/lib/db.ts` and call `SECURITY DEFINER` PL/pgSQL functions. +- **Where to get:** Neon Dashboard → Project → Connection Details → Connection string (pooled) +- **Example:** `postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/routecommerce?sslmode=require` +- **Format:** `postgres://user:pass@host:port/dbname?sslmode=require` + +#### `DATABASE_ADMIN_URL` (optional) +Same connection string but with elevated perms (typically the direct, non-pooled Neon connection). Only used by `scripts/migrate.js` for DDL — fall back to `DATABASE_URL` if unset. ### Stripe @@ -168,7 +163,6 @@ Controls whether to use Square sandbox or production. - **Wrong Stripe key type:** Using `sk_live_` in development or `sk_test_` in production will silently fail. Always match key type to environment. - **Stripe price IDs drift:** If you create new prices in Stripe Dashboard but forget to update `.env.local`, billing will fail. Keep them in sync. - **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type. -- **SUPABASE_SERVICE_ROLE_KEY in client:** If you ever see `SUPABASE_SERVICE_ROLE_KEY` in a browser bundle, it's a critical security incident. The key was exposed server-side. Rotate it immediately in Supabase Dashboard. - **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key. --- diff --git a/LAUNCH_CHECKLIST.md b/LAUNCH_CHECKLIST.md index cb96c50..ee0104e 100644 --- a/LAUNCH_CHECKLIST.md +++ b/LAUNCH_CHECKLIST.md @@ -8,15 +8,15 @@ This document summarizes the complete Launch & Marketing Layer implementation fo ## ✅ Implementation Status -### 1. Supabase Foundation ✓ +### 1. Database & Auth Foundation ✓ | Component | Status | Location | |-----------|--------|----------| -| Auth Flow | ✓ Complete | Existing dev_session + Supabase Auth | -| Database Structure | ✓ Complete | Supabase migrations in place | -| Role-Based Access | ✓ Complete | admin-permissions.ts system | -| Protected Routes | ✓ Complete | middleware.ts | -| Server Actions Pattern | ✓ Complete | src/actions/*.ts | +| Auth Flow | ✓ Complete | Neon Auth (Better Auth) + dev_session for local | +| Database Structure | ✓ Complete | `db/migrations/` applied via `pg` | +| Role-Based Access | ✓ Complete | `admin-permissions.ts` system | +| Protected Routes | ✓ Complete | `middleware.ts` | +| Server Actions Pattern | ✓ Complete | `src/actions/*.ts` | ### 2. Launch-Ready Features ✓ @@ -237,7 +237,7 @@ Track these metrics post-launch: - **Documentation**: See CLAUDE.md for technical details - **Stripe Dashboard**: https://dashboard.stripe.com -- **Supabase Dashboard**: https://app.supabase.com/project/wnzkhezyhnfzhkhiflrp +- **Neon Console**: https://console.neon.tech - **Vercel Deployments**: https://vercel.com/dashboard --- diff --git a/MEMORY.md b/MEMORY.md index 71eee96..56883d5 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -2,7 +2,7 @@ This file captures key context, decisions, fixes, and state from recent work so it survives across conversations. -**Last updated:** 2026-06 (admin_users schema fix + migration reliability + Google sign-in work) +**Last updated:** 2026-06 (admin_users schema fix + migration reliability + Google sign-in work; full Supabase purge) ## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043) @@ -48,80 +48,79 @@ See also the plan doc referenced in deploy.yml for the broader reliability work. ## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres -The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture. +**Status: COMPLETE (2026-06).** All Supabase references removed from the codebase: +- `supabase/` directory deleted (`config.toml`, `push-migrations.js`, `ADMIN_CREATE_STOP_FIX.sql`, and 137 archived migration files in `supabase/migrations/.archived/` are gone). +- `@supabase/ssr` and `@supabase/supabase-js` removed from `package.json`. +- `next.config.ts` no longer whitelists `*.supabase.co` for images; `vercel.json` CSP no longer allows `https://*.supabase.co` in `connect-src`. +- `eslint.config.mjs` no longer ignores `supabase/**`. +- All `src/`, `tests/`, and `db/` files are free of `@supabase/*` imports, `rest/v1/` calls, and `*.supabase.co` references. +- Migrations are now applied by `scripts/migrate.js` (which uses `pg` + `DATABASE_URL`); the Supabase CLI branch is gone with the deleted script. -### What changes immediately -- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`. -- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`. -- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**). -- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth. -- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**. +This section is kept as **historical record** of the cutover. The "Supabase CLI + Migrations Tooling" section further below is also historical. -### What's TBD / needs follow-up -- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet) -- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided** -- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md) -- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings? -- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition -- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally? +### Original pivot summary (kept for context) +The platform moved off Supabase entirely. We connect to **Postgres directly** (via `pg`), with **Neon Auth (Better Auth)** handling authentication. See `CLAUDE.md` for the current architecture. + +### What changed at the time +- **DB connection**: `DATABASE_URL` (with `DATABASE_ADMIN_URL` optional override for DDL) is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`. +- **Migrations**: `scripts/migrate.js` is the canonical runner. It reads `DATABASE_URL` from `.env.local` via `dotenv`, tracks applied files in `_migrations`, and applies any new files in `db/migrations/` in lexical order inside a transaction. +- **Code**: zero `@supabase/*` imports, zero `rest/v1/` REST fetches, zero Supabase JS client usage. One shared `pg` `Pool` in `src/lib/db.ts`; Drizzle on top of it at `db/client.ts` for typed reads + RLS-style brand scoping. +- **Auth**: Neon Auth (Better Auth) is the production path. `dev_session` cookie is the local-only bypass. +- **Storage**: object store migration (S3-compatible) is independent of the Supabase purge and is tracked separately. ### Migration content that's now obsolete - **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice. -- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world. -- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work. +- **Any RLS policy on tables** (200 added several): the "no row-level policies, app-layer scoping" model still holds but the policies are inert in the new world. ### Historical sections below -The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied. +The "Supabase CLI + Migrations Tooling" section further down describes the *previous* tooling. It is kept as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use `scripts/migrate.js` (or `npm run migrate`) instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied. --- -## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)* +## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above; script and CLI are no longer in the repo)* ### Login + Link (done in this session) - User ran `supabase login` - We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp` - Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`. -- Link state lives under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root. -- This enables `supabase db query --linked`, `supabase migration list`, etc. +- Link state lived under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root. +- This enabled `supabase db query --linked`, `supabase migration list`, etc. ### Important Environment Note - Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace: - `getaddrinfo ENOTFOUND` - IPv6 "network is unreachable" in some cases - HTTPS / Supabase REST / Management API work fine. -- Therefore the `push-migrations.js` **CLI path** (using linked project) is the only reliable way to apply migrations here. +- Therefore the `push-migrations.js` **CLI path** (using linked project) was the only reliable way to apply migrations here. -### Updated Migration Script -File: `supabase/push-migrations.js` +### Migration Script (deleted 2026-06 — use `scripts/migrate.js` instead) +File: `supabase/push-migrations.js` *(deleted)* -Key changes: -- Detection logic now recognizes modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`). -- `pushWithCli()` rewritten to apply files one-by-one using: +Key behavior (historical): +- Detection logic recognized modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`). +- `pushWithCli()` applied files one-by-one using: ```bash supabase db query --linked --file "supabase/migrations/NNN_foo.sql" ``` (Previously tried `db push --db-url ...` which used direct connection and failed here.) -- Falls back to direct `pg` only if CLI path fails. -- Header comments updated with current recommended workflow. +- Fell back to direct `pg` only if CLI path failed. -**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):** +**Historical commands (Supabase CLI path — both scripts are gone):** ```bash -# Supabase CLI path (legacy — do not use going forward) +# Supabase CLI path (legacy — script deleted, do not use) supabase login supabase link --project-ref wnzkhezyhnfzhkhiflrp -node supabase/push-migrations.js 148 # CLI path +node supabase/push-migrations.js 148 # CLI path (script deleted) # or npm run migrate:one 148 ``` ```bash -# Direct pg path (this is the future — only the pg branch is kept alive in the script) -DATABASE_URL=postgres://... node supabase/push-migrations.js 148 -# or +# Direct pg path (this was the future — now the only path, via scripts/migrate.js) DATABASE_URL=postgres://... npm run migrate:one 148 ``` -`npm run migrate` (no arg) will push every `*.sql` in order (use with caution). +`npm run migrate` (no arg) pushes every `*.sql` in `db/migrations/` in order (use with caution). --- @@ -210,16 +209,14 @@ Verification queries (post-apply) confirmed: --- -## Current State / Gotchas (2026-06-06) +## Current State / Gotchas (2026-06) -- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only. -- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.) -- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase. -- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`. -- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch. -- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs. +- Use `DATABASE_URL` + `pg` directly (via `scripts/migrate.js`). The Supabase CLI and `supabase/push-migrations.js` are no longer in the repo. +- `npm run migrate` applies every pending `db/migrations/*.sql` in lexical order inside transactions; `_migrations` tracks which files are already applied. +- Many migrations are intentionally written to be re-runnable (`CREATE OR REPLACE FUNCTION`, `CREATE TABLE IF NOT EXISTS`, guarded triggers). Re-pushing a prefix is safe. +- When adding **new** migrations, use the established numeric prefix style (`NNN_descriptive_name.sql`) and place them in `db/migrations/`. No Supabase CLI command is involved. +- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs (plus the per-request `app.current_brand_id` GUC used by Drizzle). - CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha. -- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js). --- @@ -234,9 +231,9 @@ Verification queries (post-apply) confirmed: ## Unrelated / Other Changes in Tree (as of last git status) -(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, `supabase/ADMIN_CREATE_STOP_FIX.sql`, a pricing assessment doc, etc.) +(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, a pricing assessment doc, etc.) -This MEMORY.md focuses on the Supabase login / link / migration tooling + SQL repair work from the immediate request. +This MEMORY.md originally focused on the Supabase login / link / migration tooling + SQL repair work from the immediate request. The pivot section now records the completion of the Supabase → Postgres migration. --- diff --git a/PRODUCTION_DEPLOYMENT_CHECKLIST.md b/PRODUCTION_DEPLOYMENT_CHECKLIST.md index 10b5388..3f49b81 100644 --- a/PRODUCTION_DEPLOYMENT_CHECKLIST.md +++ b/PRODUCTION_DEPLOYMENT_CHECKLIST.md @@ -2,7 +2,7 @@ **Platform Version:** 1.6 **Last Updated:** 2026-05-13 -**Environment:** Next.js 16 (App Router) · Supabase · Stripe · Square · Resend · FedEx +**Environment:** Next.js 16 (App Router) · Postgres (direct via `pg`) · Neon Auth (Better Auth) · Stripe · Square · Resend · FedEx --- @@ -11,14 +11,14 @@ Apply migrations in number order. All numbered `001`–`092` are required. ```bash -# Push all migrations (sequential) +# Push all migrations (sequential, no Supabase CLI required) npm run migrate:one 001 npm run migrate:one 002 # ... through ... npm run migrate:one 092 -# Or push all at once via Supabase CLI -supabase db push +# Or push all at once +npm run migrate ``` **Key migrations (last 15):** @@ -53,10 +53,11 @@ Copy `.env.local.example` to `.env.local` and fill in all values. | Variable | Required | Description | |---|---|---| -| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL (e.g., `https://xxx.supabase.co`) | -| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anon/public key (safe to expose in browser) | -| `SUPABASE_SERVICE_ROLE_KEY` | Yes | Supabase service role key — **never expose in browser**, server-side only | -| `SUPABASE_PAT` | Yes | Supabase Personal Access Token for migrations CLI | +| `DATABASE_URL` | Yes | Postgres connection string (e.g., `postgresql://user:pass@host:5432/dbname?sslmode=require`) | +| `DATABASE_ADMIN_URL` | No | Direct (non-pooled) Postgres connection with elevated perms. Only used by `scripts/migrate.js` for DDL — falls back to `DATABASE_URL` if unset. | +| `NEON_AUTH_BASE_URL` | Yes | Neon Auth (Better Auth) base URL | +| `NEON_AUTH_COOKIE_SECRET` | Yes | Neon Auth session cookie signing secret (min 32 chars) | +| `NEXT_PUBLIC_SITE_URL` | Yes | Public site URL used for auth redirects and Origin header | ### Payments @@ -198,17 +199,13 @@ For scheduled tasks (stop reminders, campaign sends), add to `vercel.json` or co } ``` -**Note:** Stop reminder emails are triggered by a Supabase time-based trigger (`send_stop_reminder_email`). Verify the trigger is active: -```sql -SELECT * FROM pg_publication_tables WHERE tablename = 'stops'; -``` -If not, recreate: `ALTER PUBLICATION supabase_realtime PUBLISH INSERT, UPDATE ON TABLE stops;` +**Note:** Stop reminder emails are fired by Vercel cron jobs (`/api/cron/send-stop-reminders`), not by a database trigger. Verify the cron is configured in `vercel.json` (or Vercel Dashboard → Project → Cron Jobs). --- ## 5. Feature Flag Enablement (Post-Deploy) -After deployment, enable add-ons per brand via the admin UI or Supabase SQL: +After deployment, enable add-ons per brand via the admin UI or direct Postgres: ```sql -- Enable Harvest Reach for a brand @@ -302,13 +299,13 @@ Vercel keeps 10 recent deployments. For critical regressions, promote the previo ### Database Rollback ```bash -# Revert last migration via Supabase -supabase db reset --backup-id +# Revert last migration via Postgres +# (Restore from a `pg_dump` snapshot taken before the migration was applied) # Or manually revert (example for migration 090) psql $DATABASE_URL -c "ALTER TABLE brand_settings DROP COLUMN IF EXISTS schedule_pdf_notes;" ``` -> **Important:** Only use `supabase db reset` on staging. In production, manually revert individual migrations to avoid data loss. +> **Important:** Always restore from a known-good backup. In production, manually revert individual migrations to avoid data loss. ### Environment Variable Rollback Environment variables are version-controlled in Vercel. Restore a previous version in **Vercel Dashboard → Project → Environment Variables → History**. @@ -318,16 +315,16 @@ Environment variables are version-controlled in Vercel. Restore a previous versi ## 8. Known Limitations & Future Work ### Known Issues -- **`dev_session` auth**: The `dev_session` cookie bypasses real Supabase auth. In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`. +- **`dev_session` auth**: The `dev_session` cookie bypasses real Neon Auth (Better Auth). In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`. - **Water Log visible to all brand admins**: Currently Tuxedo-only by brand ID check. If another brand admin needs access, the check needs to be made configurable. -- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have RLS disabled. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct Supabase queries. +- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have no row-level policies. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct SQL from client-side code. - **`event_id` not populated in message logs**: `send_campaign` and `send_stop_blast` do not set `event_id`. The Resend webhook looks up logs by `customer_email + subject` instead. This is a known limitation — if Resend events are delayed, the lookup may fail to match. - **Square sync is one-directional**: Products import from Square → Route Commerce, but product edits in Route Commerce do not push back to Square. - **SMS opt-in defaults to `FALSE`**: `communication_contacts.sms_opt_in` defaults to `false`. Users must explicitly opt in for SMS campaigns. - **`brand_id: null` for platform admins**: `getAdminUser()` returns `brand_id: null` for `platform_admin` role. Always pass explicit `brandId` to server action functions. - **Square webhook signature mismatch**: The Square webhook handler uses `crypto.createHmac("sha256", signatureKey)` but Square sends the signature as base64-encoded. Verify signature encoding matches Square's documentation before enabling Square webhooks in production. - **Stripe webhook reliability**: Stripe webhook delivery is not guaranteed to be in-order or immediate. The webhook handler is idempotent — it can safely receive duplicate events. Consider implementing a webhook event log table to track processed event IDs for deduplication. -- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct Supabase queries to these tables in client-side code. +- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct SQL to these tables in client-side code. ### Future Work (Non-Blocking) - **Cursor-based pagination**: Replace offset-based pagination (`page * limit`) with cursor-based pagination for large datasets (orders, contacts) @@ -344,11 +341,9 @@ Environment variables are version-controlled in Vercel. Restore a previous versi | Role | Contact | |------|---------| -| Platform Admin | Set via Supabase Auth dashboard | +| Platform Admin | First Neon Auth user becomes the platform admin via `provision-admin.ts` | | Kyle Martinez | Primary contact — Route Commerce owner | -| Supabase Support | supabase.com/support | +| Neon Support | neon.tech/support | | Vercel Support | vercel.com/support | -For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button. - -For Supabase production incidents: **supabase.com/dashboard** → project → "Support" tab. \ No newline at end of file +For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button. \ No newline at end of file diff --git a/PRODUCTION_SETUP.md b/PRODUCTION_SETUP.md index 2994255..18a7e9c 100644 --- a/PRODUCTION_SETUP.md +++ b/PRODUCTION_SETUP.md @@ -15,16 +15,16 @@ npm run dev ## Configuration -### 1. Clerk Authentication +### 1. Neon Auth (Better Auth) -1. Sign up at [Clerk](https://clerk.com) -2. Create a new application -3. Copy keys to `.env.local`: +1. Set up Neon Auth on your Neon project (`neonctl neon-auth init`) +2. Copy keys to `.env.local`: ``` - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx - CLERK_SECRET_KEY=sk_test_xxx + NEON_AUTH_BASE_URL=https://xxx.neonauth.io + NEON_AUTH_COOKIE_SECRET= + NEXT_PUBLIC_SITE_URL=https://yourdomain.com ``` -4. Configure middleware in `src/proxy.ts` +3. Configure middleware in `src/middleware.ts` ### 2. Stripe Payments @@ -37,15 +37,15 @@ npm run dev STRIPE_WEBHOOK_SECRET=whsec_xxx ``` -### 3. Supabase Database +### 3. Database (Postgres / Neon) -1. Create project at [Supabase](https://supabase.com) +The app talks to Postgres directly via `pg` (no Supabase, no REST gateway). Point `DATABASE_URL` at any reachable Postgres — Neon, Supabase (the underlying Postgres), RDS, etc. The Supabase JS client and PostgREST are not used. + +1. Provision a Postgres database (Neon recommended — it also hosts Neon Auth) 2. Run migrations: `npm run migrate` 3. Update `.env.local` with: ``` - NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co - NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx - SUPABASE_SERVICE_ROLE_KEY=xxx + DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require ``` ### 4. Optional Services @@ -74,7 +74,7 @@ Ensure environment variables are set before deployment. ## Key Files -- `src/proxy.ts` - Clerk middleware +- `src/middleware.ts` - Neon Auth (Better Auth) middleware - `src/lib/stripe-billing.ts` - Stripe integration - `src/lib/analytics.ts` - PostHog analytics - `src/lib/sentry.ts` - Sentry error tracking @@ -82,7 +82,7 @@ Ensure environment variables are set before deployment. - `src/components/onboarding/OnboardingFlow.tsx` - User onboarding - `src/components/referral/ReferralSystem.tsx` - Referral tracking - `src/components/changelog/ChangelogFeed.tsx` - Product updates -- `supabase/migrations/` - Database schema +- `db/migrations/` - Database schema (applied via `scripts/migrate.js`) ## Scripts diff --git a/README.md b/README.md index c623929..799019b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Route Commerce helps produce brands run their wholesale operations: ## Tech Stack - **Framework:** Next.js 16 (App Router) -- **Database:** Supabase (Postgres + Auth + RLS) +- **Database:** Postgres (direct via `pg` + Drizzle) with Neon Auth (Better Auth) - **Payments:** Stripe + Square - **Email/SMS:** Resend - **Styling:** Tailwind CSS v4 @@ -37,10 +37,8 @@ npm install Create a `.env.local` file with: ```bash -# Supabase -NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key -SUPABASE_SERVICE_ROLE_KEY=your-service-role-key +# Database (Postgres) +DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require # Stripe STRIPE_SECRET_KEY=sk_test_... @@ -56,10 +54,9 @@ RESEND_API_KEY=re_... ### 3. Set up the database -Link your Supabase project, then push migrations: +The migrations live in `db/migrations/` and are applied via the `pg` driver (no Supabase CLI): ```bash -supabase link --project-ref npm run migrate ``` @@ -111,7 +108,7 @@ src/ │ ├── admin/ # Admin-specific components │ └── storefront/ # Storefront components └── lib/ # Utilities, auth, feature flags, formatting -supabase/ +db/ └── migrations/ # SQL migrations (numbered sequentially) ``` @@ -315,8 +312,8 @@ curl -X POST https://route-commerce-platform.vercel.app/api/email-automation/wel ## Notes -- All database writes go through **server actions** (`src/actions/`), not the Supabase JS client directly -- Dev mode bypasses Supabase auth via `dev_session` cookie — never use this in production +- All database writes go through **server actions** (`src/actions/`) over a shared `pg` `Pool` from `src/lib/db.ts` — no Supabase JS client, no REST gateway +- Dev mode bypasses Neon Auth via `dev_session` cookie — never use this in production - Brand-scoped data is enforced at the application layer via `p_brand_id` parameters in SECURITY DEFINER RPCs - Display dates use `formatDate()` (MM/DD/YYYY) — never raw `toLocaleDateString()` - Migration files are numbered sequentially — never reuse numbers \ No newline at end of file diff --git a/REPORT.md b/REPORT.md index d861882..8897fa1 100644 --- a/REPORT.md +++ b/REPORT.md @@ -4,6 +4,8 @@ **Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out all Supabase references from the auth/admin path. +> **Note (2026-06-25):** Auth has since moved from Auth.js (NextAuth v5) to **Neon Auth (Better Auth)** — see `CLAUDE.md` for the current architecture. The Supabase purge described below is complete: `supabase/` directory deleted, `@supabase/*` deps removed from `package.json`, `next.config.ts` / `vercel.json` / `eslint.config.mjs` cleaned of Supabase hostnames and ignore patterns, and the remaining `*.supabase.co` references are limited to historical/educational comments in `MEMORY.md` and `docs/`. + --- ## What was built @@ -171,7 +173,7 @@ src/actions/ai/preferences.ts 1. **Provision a `pg` pool in production.** `DATABASE_URL` must be set in the hosting dashboard (same env var as - `supabase/push-migrations.js` uses). + `scripts/migrate.js` uses). 2. **Apply migration 204** if not already applied — adds `email`, `auth_provider`, `auth_subject` columns to `admin_users` and the `get_admin_user_for_session` RPC. @@ -192,6 +194,15 @@ src/actions/ai/preferences.ts 7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from `package.json`** (last step once no file imports them). +### Resolution (2026-06-25) + +Follow-ups 6 and 7 (and the data-fetching migration of all 33 files) are +complete. The codebase is now Supabase-free at the source-code level — +`grep -rln '@supabase\|rest/v1' src/` returns zero matches. The only +remaining mentions of "Supabase" are in this report, `MEMORY.md`, and a +handful of historical `docs/superpowers/{plans,specs}/` files, all kept +as historical record of the migration. + --- ## Commands diff --git a/db/seeds/2026-tuxedo-tour-stops.sql b/db/seeds/2026-tuxedo-tour-stops.sql index f6003f8..08a959f 100644 --- a/db/seeds/2026-tuxedo-tour-stops.sql +++ b/db/seeds/2026-tuxedo-tour-stops.sql @@ -2,7 +2,6 @@ -- Stops: 269 | Locations: 40 -- Preferred: npx tsx scripts/import-tuxedo-stops.ts -- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql --- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql BEGIN; diff --git a/docs/water-log.md b/docs/water-log.md index d213ad8..8037924 100644 --- a/docs/water-log.md +++ b/docs/water-log.md @@ -180,7 +180,7 @@ data explicitly checks its gate. cold-start paths; the active `getAdminUser().brand_id` always wins on later requests. - No Supabase REST, no service-role keys — all access is direct - through Drizzle over a single `pg` Pool. + through Drizzle over a single shared `pg` Pool (see `src/lib/db.ts`). --- diff --git a/eslint.config.mjs b/eslint.config.mjs index 38cdc8b..2126891 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,7 +14,6 @@ const eslintConfig = defineConfig([ "next-env.d.ts", // Ignore legacy .js scripts that use CommonJS "scripts/**", - "supabase/**", "fix-agents.js", ]), // Allow setState in useEffect for PWA prompts and client-side state initialization @@ -26,7 +25,7 @@ const eslintConfig = defineConfig([ }, // Relax some rules for legacy code { - files: ["db/**", "scripts/**", "supabase/**"], + files: ["db/**", "scripts/**"], rules: { "@typescript-eslint/no-require-imports": "off", "@typescript-eslint/no-explicit-any": "warn", diff --git a/next.config.ts b/next.config.ts index 074e801..0f55004 100644 --- a/next.config.ts +++ b/next.config.ts @@ -22,10 +22,6 @@ const nextConfig: NextConfig = { // Optimize images images: { remotePatterns: [ - { - protocol: "https", - hostname: "*.supabase.co", - }, { protocol: "https", hostname: "images.unsplash.com", @@ -124,11 +120,12 @@ const nextConfig: NextConfig = { { source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false }, { source: "/admin/stops", destination: "/admin/v2/stops", permanent: false }, { source: "/admin/products", destination: "/admin/v2/products", permanent: false }, - // v1 admin pages that hit the dead supabase shim. There are no v2 - // equivalents yet (reports / taxes / settings sub-pages are still - // TBD on the v2 surface), so they redirect to the v2 dashboard - // for now. Once those modules ship on v2, the redirects can be - // tightened to point at the new pages. + // v1 admin pages that were removed when the supabase shim was + // deleted. There are no v2 equivalents yet (reports / taxes / + // settings sub-pages are still TBD on the v2 surface), so they + // redirect to the v2 dashboard for now. Once those modules ship + // on v2, the redirects can be tightened to point at the new + // pages. { source: "/admin/products/:id", destination: "/admin/v2/products", permanent: false }, { source: "/admin/reports", destination: "/admin/v2", permanent: false }, { source: "/admin/taxes", destination: "/admin/v2", permanent: false }, diff --git a/package.json b/package.json index a22f832..5f470f7 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,6 @@ "@sentry/nextjs": "^10.55.0", "@stripe/react-stripe-js": "^6.6.0", "@stripe/stripe-js": "^9.7.0", - "@supabase/ssr": "^0.10.2", - "@supabase/supabase-js": "^2.105.3", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.38.0", "drizzle-orm": "^0.36.4", diff --git a/scripts/import-tuxedo-stops.ts b/scripts/import-tuxedo-stops.ts index edb3462..805f3f7 100644 --- a/scripts/import-tuxedo-stops.ts +++ b/scripts/import-tuxedo-stops.ts @@ -305,8 +305,7 @@ async function main() { let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`; sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`; sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`; - sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n`; - sql += `-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql\n\n`; + sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n\n`; sql += `BEGIN;\n\n`; // Locations first (master directory) diff --git a/src/actions/storefront.ts b/src/actions/storefront.ts index f940725..3792f6b 100644 --- a/src/actions/storefront.ts +++ b/src/actions/storefront.ts @@ -3,11 +3,6 @@ /** * Public storefront data accessors. * - * These replace the legacy `lib/supabase.ts` query-builder shim that the - * tuxedo / indian-river-direct storefront pages used to call client-side. - * The shim was a no-op that returned `{ data: null }`, leaving storefronts - * with empty stops / products and no real brand record. - * * Each function hits Postgres directly via the shared `pg` pool from * `@/lib/db` and is safe to call from public (unauthenticated) pages * because the SQL filters by `is_public = true` / `active = true` and @@ -122,10 +117,8 @@ export async function getStorefrontProducts( /** * Single server action that the storefront Client Components call from - * their initial `useEffect`. Replaces the three separate `supabase.from(...)` - * calls that the legacy `lib/supabase.ts` shim used to make — the shim - * returned `{ data: null }` for everything, which is why storefronts - * were showing empty stops / products. + * their initial `useEffect`. Bundles brand lookup + public stops + + * active products into one round-trip-friendly call. * * Returns `{ brand: null, stops: [], products: [] }` if the slug doesn't * match any brand — pages fall back to default copy in that case. diff --git a/src/lib/db.ts b/src/lib/db.ts index f902c2e..28f0eef 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -11,9 +11,9 @@ * const { rows } = await query("SELECT * FROM my_table WHERE id = $1", [id]); * * Configuration: - * - DATABASE_URL (required) — full Postgres connection string. Same env var - * is used by `supabase/push-migrations.js` and any external migration - * tooling. Format: `postgres://user:pass@host:port/dbname`. + * - DATABASE_URL (required) — full Postgres connection string. Used by + * `scripts/migrate.js` (and any external migration tooling). Format: + * `postgres://user:pass@host:port/dbname`. * * Notes: * - This module is server-only. It must never be imported from a Client diff --git a/supabase/.gitignore b/supabase/.gitignore deleted file mode 100644 index ad9264f..0000000 --- a/supabase/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Supabase -.branches -.temp - -# dotenvx -.env.keys -.env.local -.env.*.local diff --git a/supabase/ADMIN_CREATE_STOP_FIX.sql b/supabase/ADMIN_CREATE_STOP_FIX.sql deleted file mode 100644 index 1237509..0000000 --- a/supabase/ADMIN_CREATE_STOP_FIX.sql +++ /dev/null @@ -1,92 +0,0 @@ --- ============================================================================ --- Grok Fix Prompt: admin_create_stop (copy-paste ready for Supabase SQL Editor) --- ============================================================================ --- This is the exact "Best Practice Version" from the fix prompt, with --- additional comments. Run this directly in the Supabase SQL Editor --- (https://supabase.com/dashboard/project/wnzkhezyhnfzhkhiflrp/sql) if the --- migration push cannot be used. --- --- After running, PostgREST may need a schema reload: --- NOTIFY pgrst, 'reload schema'; --- --- Then test "Add New Stop" again. --- --- The calling code (JS/TS) is in: --- src/actions/stops/create-stop.ts (the fetch to /rest/v1/rpc/admin_create_stop) --- src/components/admin/AddStopModal.tsx --- src/components/admin/NewStopForm.tsx --- (all go through the server action createStop which builds the p_* args) --- ============================================================================ - -CREATE OR REPLACE FUNCTION public.admin_create_stop( - p_active boolean, - p_address text, - p_brand_id uuid, - p_city text, - p_cutoff_time time, - p_date date, - p_location text, - p_state text, - p_time time, - p_zip text -) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - new_stop_id uuid; -BEGIN - INSERT INTO stops ( - active, - address, - brand_id, - city, - cutoff_time, - date, - location, - state, - time, - zip - ) - VALUES ( - p_active, - p_address, - p_brand_id, - p_city, - p_cutoff_time, - p_date, - p_location, - p_state, - p_time, - p_zip - ) - RETURNING id INTO new_stop_id; - - RETURN jsonb_build_object( - 'success', true, - 'stop_id', new_stop_id, - 'message', 'Stop created successfully' - ); -EXCEPTION WHEN OTHERS THEN - RETURN jsonb_build_object( - 'success', false, - 'error', SQLERRM - ); -END; -$$; - --- Important: grant so PostgREST (anon key from server action) can invoke it. -GRANT EXECUTE ON FUNCTION public.admin_create_stop( - boolean, text, uuid, text, time, date, text, text, time, text -) TO anon, authenticated, service_role; - --- Reload cache so the function is immediately visible (avoids PGRST202). -NOTIFY pgrst, 'reload schema'; - --- NOTE: The above simple version may fail on INSERT because the stops table --- requires "slug" and "status" (NOT NULL, no defaults in all cases). --- Prefer the enhanced version in migrations/202_fix_admin_create_stop.sql --- which includes slug generation and status='draft' while using a compatible --- signature and the success/stop_id return shape. diff --git a/supabase/config.toml b/supabase/config.toml deleted file mode 100644 index 4f672e1..0000000 --- a/supabase/config.toml +++ /dev/null @@ -1,408 +0,0 @@ -# For detailed configuration reference documentation, visit: -# https://supabase.com/docs/guides/local-development/cli/config -# A string used to distinguish different Supabase projects on the same host. Defaults to the -# working directory name when running `supabase init`. -project_id = "route_commerce-main" - -[api] -enabled = true -# Port to use for the API URL. -port = 54321 -# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API -# endpoints. `public` and `graphql_public` schemas are included by default. -schemas = ["public", "graphql_public"] -# Extra schemas to add to the search_path of every request. -extra_search_path = ["public", "extensions"] -# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size -# for accidental or malicious requests. -max_rows = 1000 - -[api.tls] -# Enable HTTPS endpoints locally using a self-signed certificate. -enabled = false -# Paths to self-signed certificate pair. -# cert_path = "../certs/my-cert.pem" -# key_path = "../certs/my-key.pem" - -[db] -# Port to use for the local database URL. -port = 54322 -# Port used by db diff command to initialize the shadow database. -shadow_port = 54320 -# Maximum amount of time to wait for health check when starting the local database. -health_timeout = "2m" -# The database major version to use. This has to be the same as your remote database's. Run `SHOW -# server_version;` on the remote database to check. -major_version = 17 - -[db.pooler] -enabled = false -# Port to use for the local connection pooler. -port = 54329 -# Specifies when a server connection can be reused by other clients. -# Configure one of the supported pooler modes: `transaction`, `session`. -pool_mode = "transaction" -# How many server connections to allow per user/database pair. -default_pool_size = 20 -# Maximum number of client connections allowed. -max_client_conn = 100 - -# [db.vault] -# secret_key = "env(SECRET_VALUE)" - -[db.migrations] -# If disabled, migrations will be skipped during a db push or reset. -enabled = true -# Specifies an ordered list of schema files that describe your database. -# Supports glob patterns relative to supabase directory: "./schemas/*.sql" -schema_paths = [] - -[db.seed] -# If enabled, seeds the database after migrations during a db reset. -enabled = true -# Specifies an ordered list of seed files to load during db reset. -# Supports glob patterns relative to supabase directory: "./seeds/*.sql" -sql_paths = ["./seed.sql"] - -[db.network_restrictions] -# Enable management of network restrictions. -enabled = false -# List of IPv4 CIDR blocks allowed to connect to the database. -# Defaults to allow all IPv4 connections. Set empty array to block all IPs. -allowed_cidrs = ["0.0.0.0/0"] -# List of IPv6 CIDR blocks allowed to connect to the database. -# Defaults to allow all IPv6 connections. Set empty array to block all IPs. -allowed_cidrs_v6 = ["::/0"] - -# Uncomment to reject non-secure connections to the database. -# [db.ssl_enforcement] -# enabled = true - -[realtime] -enabled = true -# Bind realtime via either IPv4 or IPv6. (default: IPv4) -# ip_version = "IPv6" -# The maximum length in bytes of HTTP request headers. (default: 4096) -# max_header_length = 4096 - -[studio] -enabled = true -# Port to use for Supabase Studio. -port = 54323 -# External URL of the API server that frontend connects to. -api_url = "http://127.0.0.1" -# OpenAI API Key to use for Supabase AI in the Supabase Studio. -openai_api_key = "env(OPENAI_API_KEY)" - -# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they -# are monitored, and you can view the emails that would have been sent from the web interface. -[inbucket] -enabled = true -# Port to use for the email testing server web interface. -port = 54324 -# Uncomment to expose additional ports for testing user applications that send emails. -# smtp_port = 54325 -# pop3_port = 54326 -# admin_email = "admin@email.com" -# sender_name = "Admin" - -[storage] -enabled = true -# The maximum file size allowed (e.g. "5MB", "500KB"). -file_size_limit = "50MiB" - -# Uncomment to configure local storage buckets -# [storage.buckets.images] -# public = false -# file_size_limit = "50MiB" -# allowed_mime_types = ["image/png", "image/jpeg"] -# objects_path = "./images" - -# Allow connections via S3 compatible clients -[storage.s3_protocol] -enabled = true - -# Image transformation API is available to Supabase Pro plan. -# [storage.image_transformation] -# enabled = true - -# Store analytical data in S3 for running ETL jobs over Iceberg Catalog -# This feature is only available on the hosted platform. -[storage.analytics] -enabled = false -max_namespaces = 5 -max_tables = 10 -max_catalogs = 2 - -# Analytics Buckets is available to Supabase Pro plan. -# [storage.analytics.buckets.my-warehouse] - -# Store vector embeddings in S3 for large and durable datasets -# This feature is only available on the hosted platform. -[storage.vector] -enabled = false -max_buckets = 10 -max_indexes = 5 - -# Vector Buckets is available to Supabase Pro plan. -# [storage.vector.buckets.documents-openai] - -[auth] -enabled = true -# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used -# in emails. -site_url = "http://127.0.0.1:3000" -# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. -# external_url = "" -# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["https://127.0.0.1:3000"] -# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). -jwt_expiry = 3600 -# JWT issuer URL. If not set, defaults to auth.external_url. -# jwt_issuer = "" -# Path to JWT signing key. DO NOT commit your signing keys file to git. -# signing_keys_path = "./signing_keys.json" -# If disabled, the refresh token will never expire. -enable_refresh_token_rotation = true -# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. -# Requires enable_refresh_token_rotation = true. -refresh_token_reuse_interval = 10 -# Allow/disallow new user signups to your project. -enable_signup = true -# Allow/disallow anonymous sign-ins to your project. -enable_anonymous_sign_ins = false -# Allow/disallow testing manual linking of accounts -enable_manual_linking = false -# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. -minimum_password_length = 6 -# Passwords that do not meet the following requirements will be rejected as weak. Supported values -# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` -password_requirements = "" - -# Configure passkey sign-ins. -# [auth.passkey] -# enabled = false - -# Configure WebAuthn relying party settings (required when passkey is enabled). -# [auth.webauthn] -# rp_display_name = "Supabase" -# rp_id = "localhost" -# rp_origins = ["http://127.0.0.1:3000"] - -[auth.rate_limit] -# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. -email_sent = 2 -# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. -sms_sent = 30 -# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. -anonymous_users = 30 -# Number of sessions that can be refreshed in a 5 minute interval per IP address. -token_refresh = 150 -# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). -sign_in_sign_ups = 30 -# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. -token_verifications = 30 -# Number of Web3 logins that can be made in a 5 minute interval per IP address. -web3 = 30 - -# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. -# [auth.captcha] -# enabled = true -# provider = "hcaptcha" -# secret = "" - -[auth.email] -# Allow/disallow new user signups via email to your project. -enable_signup = true -# If enabled, a user will be required to confirm any email change on both the old, and new email -# addresses. If disabled, only the new email is required to confirm. -double_confirm_changes = true -# If enabled, users need to confirm their email address before signing in. -enable_confirmations = false -# If enabled, users will need to reauthenticate or have logged in recently to change their password. -secure_password_change = false -# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. -max_frequency = "1s" -# Number of characters used in the email OTP. -otp_length = 6 -# Number of seconds before the email OTP expires (defaults to 1 hour). -otp_expiry = 3600 - -# Use a production-ready SMTP server -# [auth.email.smtp] -# enabled = true -# host = "smtp.sendgrid.net" -# port = 587 -# user = "apikey" -# pass = "env(SENDGRID_API_KEY)" -# admin_email = "admin@email.com" -# sender_name = "Admin" - -# Uncomment to customize email template -# [auth.email.template.invite] -# subject = "You have been invited" -# content_path = "./supabase/templates/invite.html" - -# Uncomment to customize notification email template -# [auth.email.notification.password_changed] -# enabled = true -# subject = "Your password has been changed" -# content_path = "./templates/password_changed_notification.html" - -[auth.sms] -# Allow/disallow new user signups via SMS to your project. -enable_signup = false -# If enabled, users need to confirm their phone number before signing in. -enable_confirmations = false -# Template for sending OTP to users -template = "Your code is {{ .Code }}" -# Controls the minimum amount of time that must pass before sending another sms otp. -max_frequency = "5s" - -# Use pre-defined map of phone number to OTP for testing. -# [auth.sms.test_otp] -# 4152127777 = "123456" - -# Configure logged in session timeouts. -# [auth.sessions] -# Force log out after the specified duration. -# timebox = "24h" -# Force log out if the user has been inactive longer than the specified duration. -# inactivity_timeout = "8h" - -# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. -# [auth.hook.before_user_created] -# enabled = true -# uri = "pg-functions://postgres/auth/before-user-created-hook" - -# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. -# [auth.hook.custom_access_token] -# enabled = true -# uri = "pg-functions:////" - -# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. -[auth.sms.twilio] -enabled = false -account_sid = "" -message_service_sid = "" -# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: -auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" - -# Multi-factor-authentication is available to Supabase Pro plan. -[auth.mfa] -# Control how many MFA factors can be enrolled at once per user. -max_enrolled_factors = 10 - -# Control MFA via App Authenticator (TOTP) -[auth.mfa.totp] -enroll_enabled = false -verify_enabled = false - -# Configure MFA via Phone Messaging -[auth.mfa.phone] -enroll_enabled = false -verify_enabled = false -otp_length = 6 -template = "Your code is {{ .Code }}" -max_frequency = "5s" - -# Configure MFA via WebAuthn -# [auth.mfa.web_authn] -# enroll_enabled = true -# verify_enabled = true - -# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, -# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, -# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. -[auth.external.apple] -enabled = false -client_id = "" -# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: -secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" -# Overrides the default auth callback URL derived from auth.external_url. -redirect_uri = "" -# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, -# or any other third-party OIDC providers. -url = "" -# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. -skip_nonce_check = false -# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. -email_optional = false - -# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. -# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. -[auth.web3.solana] -enabled = false - -# Use Firebase Auth as a third-party provider alongside Supabase Auth. -[auth.third_party.firebase] -enabled = false -# project_id = "my-firebase-project" - -# Use Auth0 as a third-party provider alongside Supabase Auth. -[auth.third_party.auth0] -enabled = false -# tenant = "my-auth0-tenant" -# tenant_region = "us" - -# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. -[auth.third_party.aws_cognito] -enabled = false -# user_pool_id = "my-user-pool-id" -# user_pool_region = "us-east-1" - -# Use Clerk as a third-party provider alongside Supabase Auth. -[auth.third_party.clerk] -enabled = false -# Obtain from https://clerk.com/setup/supabase -# domain = "example.clerk.accounts.dev" - -# OAuth server configuration -[auth.oauth_server] -# Enable OAuth server functionality -enabled = false -# Path for OAuth consent flow UI -authorization_url_path = "/oauth/consent" -# Allow dynamic client registration -allow_dynamic_registration = false - -[edge_runtime] -enabled = true -# Supported request policies: `oneshot`, `per_worker`. -# `per_worker` (default) — enables hot reload during local development. -# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). -policy = "per_worker" -# Port to attach the Chrome inspector for debugging edge functions. -inspector_port = 8083 -# The Deno major version to use. -deno_version = 2 - -# [edge_runtime.secrets] -# secret_key = "env(SECRET_VALUE)" - -[analytics] -enabled = true -port = 54327 -# Configure one of the supported backends: `postgres`, `bigquery`. -backend = "postgres" - -# Experimental features may be deprecated any time -[experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "" -# Configures S3 bucket URL, eg. .s3-.amazonaws.com -s3_host = "env(S3_HOST)" -# Configures S3 bucket region, eg. us-east-1 -s3_region = "env(S3_REGION)" -# Configures AWS_ACCESS_KEY_ID for S3 bucket -s3_access_key = "env(S3_ACCESS_KEY)" -# Configures AWS_SECRET_ACCESS_KEY for S3 bucket -s3_secret_key = "env(S3_SECRET_KEY)" - -# [experimental.pgdelta] -# When enabled, pg-delta becomes the active engine for supported schema flows. -# enabled = false -# Directory under `supabase/` where declarative files are written. -# declarative_schema_path = "./database" -# JSON string passed through to pg-delta SQL formatting. -# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" diff --git a/supabase/migrations/.archived/001_checkout_hardening.sql b/supabase/migrations/.archived/001_checkout_hardening.sql deleted file mode 100644 index 915a033..0000000 --- a/supabase/migrations/.archived/001_checkout_hardening.sql +++ /dev/null @@ -1,201 +0,0 @@ --- ============================================================ --- Checkout Hardening Migration --- ============================================================ --- 1. Add idempotency_key column to orders (UUID, unique) --- 2. Index on idempotency_key for fast idempotency checks --- 3. RPC function for atomic order + order_items creation --- 4. Returns JSONB — no post-checkout SELECT needed - --- 1. Idempotency key column -ALTER TABLE orders ADD COLUMN IF NOT EXISTS idempotency_key UUID UNIQUE; - --- 1b. Add fulfillment column to order_items for mixed pickup/ship orders -ALTER TABLE order_items ADD COLUMN IF NOT EXISTS fulfillment TEXT DEFAULT 'pickup'; - --- 2. Index for idempotency lookups -CREATE INDEX IF NOT EXISTS idx_orders_idempotency_key ON orders(idempotency_key); - --- 3. Atomic checkout RPC function --- Validates stop, products, quantities, pickup eligibility --- Computes subtotal server-side from current DB prices --- Returns JSONB with order + items — caller never needs a separate SELECT -CREATE OR REPLACE FUNCTION create_order_with_items( - p_idempotency_key UUID, - p_customer_name TEXT, - p_customer_email TEXT, - p_customer_phone TEXT, - p_stop_id UUID, - p_items JSONB -- [{id: uuid, quantity: int, fulfillment: text}] -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_order_id UUID; - v_item JSONB; - v_product_id UUID; - v_quantity INT; - v_fulfillment TEXT; - v_product_price NUMERIC; - v_computed_total NUMERIC := 0; - v_stop_active BOOLEAN; - v_stop_brand_id UUID; - v_stop_city TEXT; - v_stop_state TEXT; - v_stop_date TEXT; - v_stop_time TEXT; - v_stop_location TEXT; - v_product_active BOOLEAN; - v_product_brand_id UUID; - v_product_name TEXT; - v_is_pickup BOOLEAN; - v_order JSONB; - v_order_items JSONB := '[]'::JSONB; -BEGIN - -- ── Idempotency guard ────────────────────────────────────── - -- If this key was already used, return the existing order + items. - -- Safe for retries: the order is real, the user just didn't see the success page. - SELECT jsonb_build_object( - 'id', o.id, - 'customer_name', o.customer_name, - 'customer_email', o.customer_name, - 'customer_phone', o.customer_phone, - 'subtotal', o.subtotal, - 'status', o.status, - 'stop_id', o.stop_id, - 'stop_city', s.city, - 'stop_state', s.state, - 'stop_date', s.date, - 'stop_time', s.time, - 'stop_location', s.location, - 'items', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'product_id', oi.product_id, - 'product_name', p.name, - 'quantity', oi.quantity, - 'price', oi.price, - 'fulfillment', oi.fulfillment - )) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id - ), '[]'::JSONB) - ) - INTO v_order - FROM orders o - JOIN stops s ON o.stop_id = s.id - WHERE o.idempotency_key = p_idempotency_key - LIMIT 1; - - IF v_order IS NOT NULL THEN - RETURN v_order; - END IF; - - -- ── Validate stop ────────────────────────────────────────── - SELECT active, brand_id, city, state, date, time, location - INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location - FROM stops - WHERE id = p_stop_id; - - IF v_stop_brand_id IS NULL THEN - RAISE EXCEPTION 'Stop not found: %', p_stop_id; - END IF; - - IF NOT v_stop_active THEN - RAISE EXCEPTION 'Stop is not active: %', p_stop_id; - END IF; - - -- ── Validate items and compute subtotal ──────────────────── - FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) - LOOP - v_product_id := (v_item->>'id')::UUID; - v_quantity := (v_item->>'quantity')::INT; - v_fulfillment := v_item->>'fulfillment'; - v_is_pickup := (v_fulfillment = 'pickup'); - - SELECT active, brand_id, price, name - INTO v_product_active, v_product_brand_id, v_product_price, v_product_name - FROM products - WHERE id = v_product_id; - - IF v_product_brand_id IS NULL THEN - RAISE EXCEPTION 'Product not found: %', v_product_id; - END IF; - - IF v_product_brand_id != v_stop_brand_id THEN - RAISE EXCEPTION 'Product % does not belong to the stop brand', v_product_id; - END IF; - - IF NOT v_product_active THEN - RAISE EXCEPTION 'Product is not active: %', v_product_id; - END IF; - - IF v_quantity < 1 THEN - RAISE EXCEPTION 'Quantity must be at least 1 for product: %', v_product_id; - END IF; - - IF v_is_pickup THEN - PERFORM 1 - FROM product_stops - WHERE product_id = v_product_id AND stop_id = p_stop_id - LIMIT 1; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id; - END IF; - END IF; - - v_computed_total := v_computed_total + (v_product_price * v_quantity); - END LOOP; - - -- ── Create order ──────────────────────────────────────────── - INSERT INTO orders ( - idempotency_key, customer_name, customer_email, customer_phone, - stop_id, subtotal, status - ) VALUES ( - p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone, - p_stop_id, v_computed_total, 'pending' - ) - RETURNING id INTO v_order_id; - - -- ── Insert order items and build return value ─────────────── - FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) - LOOP - v_product_id := (v_item->>'id')::UUID; - v_quantity := (v_item->>'quantity')::INT; - v_fulfillment := v_item->>'fulfillment'; - - SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id; - - INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price) - VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price); - - -- Append to items array - v_order_items := v_order_items || jsonb_build_object( - 'product_id', v_product_id, - 'product_name', v_product_name, - 'quantity', v_quantity, - 'price', v_product_price, - 'fulfillment', v_fulfillment - ); - END LOOP; - - -- ── Build and return full order object ────────────────────── - RETURN jsonb_build_object( - 'id', v_order_id, - 'customer_name', p_customer_name, - 'customer_email', p_customer_email, - 'customer_phone', p_customer_phone, - 'subtotal', v_computed_total, - 'status', 'pending', - 'stop_id', p_stop_id, - 'stop_city', v_stop_city, - 'stop_state', v_stop_state, - 'stop_date', v_stop_date, - 'stop_time', v_stop_time, - 'stop_location', v_stop_location, - 'items', v_order_items - ); -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/002_remove_broad_anon_select.sql b/supabase/migrations/.archived/002_remove_broad_anon_select.sql deleted file mode 100644 index 57e680d..0000000 --- a/supabase/migrations/.archived/002_remove_broad_anon_select.sql +++ /dev/null @@ -1,10 +0,0 @@ --- ============================================================ --- Remove broad anon SELECT policies --- ============================================================ --- The checkout flow no longer requires post-checkout SELECT --- because the RPC returns full order data directly. --- Admin SELECT is handled by server-side auth (getAdminUser). - --- Drop the overly permissive read policies -DROP POLICY IF EXISTS "Allow public read on orders for checkout" ON orders; -DROP POLICY IF EXISTS "Allow public read on order_items" ON order_items; diff --git a/supabase/migrations/.archived/003_audit_logs.sql b/supabase/migrations/.archived/003_audit_logs.sql deleted file mode 100644 index ca9a567..0000000 --- a/supabase/migrations/.archived/003_audit_logs.sql +++ /dev/null @@ -1,103 +0,0 @@ --- ============================================================ --- Audit Logging --- ============================================================ --- Tracks critical admin mutations for compliance and debugging. --- Lightweight: single insert per action, no triggers. - -CREATE TABLE IF NOT EXISTS audit_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - table_name TEXT NOT NULL, - record_id UUID NOT NULL, - action TEXT NOT NULL, -- INSERT | UPDATE | DELETE - old_data JSONB, - new_data JSONB, - performed_by UUID, - performed_by_email TEXT, - brand_id UUID, -- for brand-scoped log reads - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Index for fast lookups by table + record -CREATE INDEX IF NOT EXISTS idx_audit_logs_table_record - ON audit_logs(table_name, record_id); - --- Index for brand-scoped reads -CREATE INDEX IF NOT EXISTS idx_audit_logs_brand - ON audit_logs(brand_id) WHERE brand_id IS NOT NULL; - --- Index for performed_by queries -CREATE INDEX IF NOT EXISTS idx_audit_logs_performed_by - ON audit_logs(performed_by); - --- RLS: no direct public access -ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; - --- Read policies (platform_admin and brand_admin read their own logs) --- Read policy: platform_admin sees all -CREATE POLICY "Platform admin reads all audit logs" - ON audit_logs FOR SELECT - TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - ) - ); - --- Read policy: brand_admin sees only their brand's logs -CREATE POLICY "Brand admin reads own brand audit logs" - ON audit_logs FOR SELECT - TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'brand_admin' - AND admin_users.brand_id = audit_logs.brand_id - ) - ); - --- Write is handled exclusively via SECURITY DEFINER RPC (bypasses RLS) --- No INSERT policy needed — the function bypasses RLS entirely. - --- ============================================================ --- log_audit_event — called by server actions to write audit records --- Runs as postgres (SUPERUSER) so it bypasses RLS on audit_logs. --- Accepts a JSONB payload with all audit fields. --- ============================================================ -CREATE OR REPLACE FUNCTION log_audit_event(p_payload JSONB) -RETURNS UUID -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_audit_id UUID; -BEGIN - INSERT INTO audit_logs ( - table_name, record_id, action, - old_data, new_data, - performed_by, performed_by_email, - brand_id - ) VALUES ( - p_payload->>'table_name', - (p_payload->>'record_id')::UUID, - p_payload->>'action', - p_payload->'old_data', - p_payload->'new_data', - CASE - WHEN (p_payload->>'performed_by') IS NULL THEN NULL - ELSE (p_payload->>'performed_by')::UUID - END, - p_payload->>'performed_by_email', - CASE - WHEN (p_payload->>'brand_id') IS NULL THEN NULL - ELSE (p_payload->>'brand_id')::UUID - END - ) - RETURNING id INTO v_audit_id; - - RETURN v_audit_id; -END; -$$; diff --git a/supabase/migrations/.archived/004_admin_order_fetch.sql b/supabase/migrations/.archived/004_admin_order_fetch.sql deleted file mode 100644 index 8242d6c..0000000 --- a/supabase/migrations/.archived/004_admin_order_fetch.sql +++ /dev/null @@ -1,193 +0,0 @@ --- ============================================================ --- Admin Read Policies for Orders --- ============================================================ --- The checkout hardening removed broad anon SELECT policies. --- Admin pages need to read orders with the anon key (server-side via getAdminUser). --- We use SECURITY DEFINER functions that bypass RLS so admin pages --- can still fetch orders for their brand scope without auth.uid(). - --- No direct public SELECT (already enforced by removal of anon policies) - --- Policy: platform_admin can SELECT all orders -CREATE POLICY "Platform admin can read orders" - ON orders FOR SELECT - TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - ) - ); - --- Policy: brand_admin can read orders for their brand -CREATE POLICY "Brand admin can read their brand orders" - ON orders FOR SELECT - TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'brand_admin' - AND admin_users.brand_id = ( - SELECT brand_id FROM stops WHERE stops.id = orders.stop_id - ) - ) - ); - --- Policy: stops readable by admin users -CREATE POLICY "Admins can read stops" - ON stops FOR SELECT - TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - ) - ); - --- ============================================================ --- Admin Orders Fetch RPC --- ============================================================ --- SECURITY DEFINER runs as postgres — bypasses RLS on orders/stops. - -CREATE OR REPLACE FUNCTION get_admin_orders(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_orders JSONB; - v_stops JSONB; -BEGIN - IF p_brand_id IS NULL THEN - SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB) - INTO v_orders - FROM ( - SELECT o.id, o.customer_name, o.customer_email, o.customer_phone, - o.stop_id, o.status, o.subtotal, o.pickup_complete, - o.pickup_completed_at, o.pickup_completed_by, o.created_at, - CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) END as stops - FROM orders o LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.stop_id IS NOT NULL - LIMIT 500 - ) t; - - SELECT COALESCE(jsonb_agg(jsonb_build_object( - 'id', id, 'city', city, 'state', state, - 'date', date, 'time', time, 'location', location, 'brand_id', brand_id - ) ORDER BY date), '[]'::JSONB) - INTO v_stops FROM stops WHERE active = true; - ELSE - SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB) - INTO v_orders - FROM ( - SELECT o.id, o.customer_name, o.customer_email, o.customer_phone, - o.stop_id, o.status, o.subtotal, o.pickup_complete, - o.pickup_completed_at, o.pickup_completed_by, o.created_at, - jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) as stops - FROM orders o JOIN stops s ON o.stop_id = s.id - WHERE s.brand_id = p_brand_id - LIMIT 500 - ) t; - - SELECT COALESCE(jsonb_agg(jsonb_build_object( - 'id', id, 'city', city, 'state', state, - 'date', date, 'time', time, 'location', location, 'brand_id', brand_id - ) ORDER BY date), '[]'::JSONB) - INTO v_stops FROM stops WHERE active = true AND brand_id = p_brand_id; - END IF; - - RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops); -END; -$$; - --- ============================================================ --- Admin Order Detail RPC --- ============================================================ - -CREATE OR REPLACE FUNCTION get_admin_order_detail(p_order_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_order JSONB; -BEGIN - SELECT jsonb_build_object( - 'id', o.id, - 'customer_name', o.customer_name, - 'customer_email', o.customer_email, - 'customer_phone', o.customer_phone, - 'stop_id', o.stop_id, - 'status', o.status, - 'subtotal', o.subtotal, - 'discount_amount', o.discount_amount, - 'tax_amount', o.tax_amount, - 'tax_rate', o.tax_rate, - 'tax_location', o.tax_location, - 'discount_reason', o.discount_reason, - 'pickup_complete', o.pickup_complete, - 'pickup_completed_at', o.pickup_completed_at, - 'pickup_completed_by', o.pickup_completed_by, - 'internal_notes', o.internal_notes, - 'payment_processor', o.payment_processor, - 'payment_status', o.payment_status, - 'payment_transaction_id', o.payment_transaction_id, - 'refunded_amount', o.refunded_amount, - 'refund_reason', o.refund_reason, - 'created_at', o.created_at, - 'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object( - 'id', s.id, - 'city', s.city, - 'state', s.state, - 'date', s.date, - 'time', s.time, - 'location', s.location, - 'brand_id', s.brand_id - ) END, - 'order_items', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'id', oi.id, - 'product_id', oi.product_id, - 'product_name', p.name, - 'quantity', oi.quantity, - 'price', oi.price, - 'fulfillment', oi.fulfillment, - 'products', jsonb_build_object('name', p.name) - )) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id - ), '[]'::JSONB), - 'refunds', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'id', r.id, - 'order_id', r.order_id, - 'amount', r.amount, - 'reason', r.reason, - 'processor', r.processor, - 'processor_refund_id', r.processor_refund_id, - 'status', r.status, - 'created_at', r.created_at - )) - FROM refunds r - WHERE r.order_id = o.id - ), '[]'::JSONB) - ) - INTO v_order - FROM orders o - LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.id = p_order_id; - - RETURN v_order; -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/005_water_log.sql b/supabase/migrations/.archived/005_water_log.sql deleted file mode 100644 index 3ddef4b..0000000 --- a/supabase/migrations/.archived/005_water_log.sql +++ /dev/null @@ -1,342 +0,0 @@ --- ============================================================ --- Water Log V1 --- ============================================================ --- Tables: water_headgates, water_irrigators, water_log_entries --- Auth: PIN-based field login via HTTP-only cookie session --- Admin: brand-scoped, SECURITY DEFINER RPCs, audit logged - --- ── Tables ───────────────────────────────────────────────── - -CREATE TABLE water_headgates ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - brand_id UUID REFERENCES brands(id) NOT NULL, - name TEXT NOT NULL, - active BOOLEAN DEFAULT true, - created_at TIMESTAMPTZ DEFAULT now() -); - -CREATE TABLE water_irrigators ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - brand_id UUID REFERENCES brands(id) NOT NULL, - name TEXT NOT NULL, - pin_hash TEXT NOT NULL, - language_preference TEXT DEFAULT 'en', - active BOOLEAN DEFAULT true, - last_used_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT now() -); - --- Water sessions for field login (HTTP-only cookie, server-side validation) -CREATE TABLE water_sessions ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - irrigator_id UUID REFERENCES water_irrigators(id) NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ DEFAULT now() -); - -CREATE TABLE water_log_entries ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - brand_id UUID REFERENCES brands(id) NOT NULL, - headgate_id UUID REFERENCES water_headgates(id) NOT NULL, - irrigator_id UUID REFERENCES water_irrigators(id) NOT NULL, - measurement NUMERIC NOT NULL, - unit TEXT NOT NULL, - notes TEXT, - submitted_via TEXT NOT NULL, -- 'field' or 'admin' - logged_at TIMESTAMPTZ DEFAULT now(), - logged_by UUID REFERENCES admin_users(id) -); - --- ── RLS ───────────────────────────────────────────────────── - -ALTER TABLE water_headgates ENABLE ROW LEVEL SECURITY; -ALTER TABLE water_irrigators ENABLE ROW LEVEL SECURITY; -ALTER TABLE water_log_entries ENABLE ROW LEVEL SECURITY; - --- Headgates: readable by anyone (for field form), writable by admin RPCs -CREATE POLICY "Headgates are readable by all" ON water_headgates FOR SELECT TO anon USING (true); -CREATE POLICY "Headgates writable by postgres" ON water_headgates FOR ALL TO postgres USING (true); - --- Irrigators: only SECURITY DEFINER RPCs read/write (pin_hash never exposed) -CREATE POLICY "Irrigators writable by postgres" ON water_irrigators FOR ALL TO postgres USING (true); - --- Entries: readable by all (for admin), writable by admin RPCs and field RPC -CREATE POLICY "Entries readable by all" ON water_log_entries FOR SELECT TO anon USING (true); -CREATE POLICY "Entries writable by postgres" ON water_log_entries FOR ALL TO postgres USING (true); - --- ── Audit Log ────────────────────────────────────────────── - -CREATE TABLE IF NOT EXISTS audit_logs ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - user_id UUID, - action TEXT NOT NULL, - entity_type TEXT NOT NULL, - entity_id UUID, - details JSONB, - ip_address TEXT, - created_at TIMESTAMPTZ DEFAULT now() -); - -ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; -CREATE POLICY "Audit logs writable by postgres" ON audit_logs FOR ALL TO postgres USING (true); - --- ── RPCs ─────────────────────────────────────────────────── - --- Verify PIN, create DB session, return session token (stored in HTTP-only cookie) -CREATE OR REPLACE FUNCTION verify_water_pin(p_brand_id UUID, p_pin TEXT) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_irr water_irrigators%ROWTYPE; - v_sess_id UUID; - v_lang TEXT; -BEGIN - SELECT * INTO v_irr FROM water_irrigators - WHERE brand_id = p_brand_id AND active = true - AND crypt(p_pin, pin_hash) = pin_hash - LIMIT 1; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN'); - END IF; - - UPDATE water_irrigators SET last_used_at = now() WHERE id = v_irr.id; - - -- Create DB session with 4-hour expiry - v_sess_id := gen_random_uuid(); - INSERT INTO water_sessions (id, irrigator_id, expires_at) - VALUES (v_sess_id, v_irr.id, now() + interval '4 hours'); - - RETURN jsonb_build_object( - 'success', true, - 'irrigator_id', v_irr.id, - 'name', v_irr.name, - 'session_id', v_sess_id, - 'lang', v_irr.language_preference - ); -END; -$$; - --- Submit water log entry (field side — session validated via DB) -CREATE OR REPLACE FUNCTION submit_water_entry( - p_session_id UUID, - p_headgate_id UUID, - p_measurement NUMERIC, - p_unit TEXT, - p_notes TEXT, - p_submitted_via TEXT -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_entry_id UUID; - v_sess water_sessions%ROWTYPE; - v_brand_id UUID; -BEGIN - -- Validate session: exists and not expired - SELECT s.*, i.brand_id INTO v_sess, v_brand_id - FROM water_sessions s - JOIN water_irrigators i ON s.irrigator_id = i.id - WHERE s.id = p_session_id AND s.expires_at > now() AND i.active = true; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid'); - END IF; - - INSERT INTO water_log_entries (brand_id, headgate_id, irrigator_id, measurement, unit, notes, submitted_via) - VALUES (v_brand_id, p_headgate_id, v_sess.irrigator_id, p_measurement, p_unit, p_notes, p_submitted_via) - RETURNING id INTO v_entry_id; - - RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id); -END; -$$; - --- Get headgates for a brand. p_active_only=true for field form, false for admin -CREATE OR REPLACE FUNCTION get_water_headgates(p_brand_id UUID, p_active_only BOOLEAN DEFAULT false) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN jsonb_build_object('headgates', ( - SELECT COALESCE(jsonb_agg(jsonb_build_object( - 'id', id, 'name', name, 'active', active, 'created_at', created_at - ) ORDER BY name), '[]'::JSONB) - FROM water_headgates - WHERE brand_id = p_brand_id - AND (NOT p_active_only OR active = true) - )); -END; -$$; - --- Get irrigators for a brand (admin, no pin_hash exposed) -CREATE OR REPLACE FUNCTION get_water_irrigators(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN jsonb_build_object('irrigators', ( - SELECT COALESCE(jsonb_agg(jsonb_build_object( - 'id', id, 'name', name, 'active', active, - 'language_preference', language_preference, - 'last_used_at', last_used_at, - 'created_at', created_at - ) ORDER BY name), '[]'::JSONB) - FROM water_irrigators - WHERE brand_id = p_brand_id - )); -END; -$$; - --- Create a new irrigator with auto-generated PIN --- Returns irrigator + plain PIN (shown once) -CREATE OR REPLACE FUNCTION create_water_irrigator(p_brand_id UUID, p_name TEXT, p_lang TEXT DEFAULT 'en') -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_irr water_irrigators%ROWTYPE; - v_raw_pin TEXT; - v_new_id UUID; -BEGIN - v_raw_pin := floor(random() * 9000 + 1000)::TEXT; -- 4-digit PIN - - INSERT INTO water_irrigators (brand_id, name, pin_hash, language_preference) - VALUES (p_brand_id, p_name, crypt(v_raw_pin, gen_salt('bf')), p_lang) - RETURNING * INTO v_irr; - - RETURN jsonb_build_object( - 'irrigator', jsonb_build_object( - 'id', v_irr.id, 'name', v_irr.name, - 'active', v_irr.active, 'language_preference', v_irr.language_preference, - 'created_at', v_irr.created_at - ), - 'pin', v_raw_pin - ); -END; -$$; - --- Update irrigator name/active -CREATE OR REPLACE FUNCTION update_water_irrigator( - p_irrigator_id UUID, p_name TEXT, p_active BOOLEAN, p_lang TEXT -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_irr water_irrigators%ROWTYPE; -BEGIN - UPDATE water_irrigators - SET name = p_name, active = p_active, language_preference = p_lang - WHERE id = p_irrigator_id - RETURNING * INTO v_irr; - - RETURN jsonb_build_object('irrigator', jsonb_build_object( - 'id', v_irr.id, 'name', v_irr.name, - 'active', v_irr.active, 'language_preference', v_irr.language_preference, - 'last_used_at', v_irr.last_used_at - )); -END; -$$; - --- Reset irrigator PIN — returns new PIN (shown once) -CREATE OR REPLACE FUNCTION reset_water_irrigator_pin(p_irrigator_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_irr water_irrigators%ROWTYPE; - v_raw_pin TEXT; -BEGIN - v_raw_pin := floor(random() * 9000 + 1000)::TEXT; - - UPDATE water_irrigators - SET pin_hash = crypt(v_raw_pin, gen_salt('bf')) - WHERE id = p_irrigator_id - RETURNING * INTO v_irr; - - RETURN jsonb_build_object('irrigator_id', v_irr.id, 'pin', v_raw_pin); -END; -$$; - --- Create headgate -CREATE OR REPLACE FUNCTION create_water_headgate(p_brand_id UUID, p_name TEXT) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_hg water_headgates%ROWTYPE; -BEGIN - INSERT INTO water_headgates (brand_id, name) - VALUES (p_brand_id, p_name) - RETURNING * INTO v_hg; - - RETURN jsonb_build_object('headgate', jsonb_build_object( - 'id', v_hg.id, 'name', v_hg.name, 'active', v_hg.active, 'created_at', v_hg.created_at - )); -END; -$$; - --- Update headgate -CREATE OR REPLACE FUNCTION update_water_headgate(p_headgate_id UUID, p_name TEXT, p_active BOOLEAN) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_hg water_headgates%ROWTYPE; -BEGIN - UPDATE water_headgates - SET name = p_name, active = p_active - WHERE id = p_headgate_id - RETURNING * INTO v_hg; - - RETURN jsonb_build_object('headgate', jsonb_build_object( - 'id', v_hg.id, 'name', v_hg.name, 'active', v_hg.active - )); -END; -$$; - --- Get recent entries for a brand -CREATE OR REPLACE FUNCTION get_water_entries(p_brand_id UUID, p_limit INT DEFAULT 50) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN jsonb_build_object('entries', ( - SELECT COALESCE(jsonb_agg(t ORDER BY t.logged_at DESC), '[]'::JSONB) - FROM ( - SELECT e.id, e.headgate_id, e.irrigator_id, e.measurement, e.unit, - e.notes, e.submitted_via, e.logged_at, - h.name as headgate_name, - i.name as irrigator_name - FROM water_log_entries e - JOIN water_headgates h ON e.headgate_id = h.id - JOIN water_irrigators i ON e.irrigator_id = i.id - WHERE e.brand_id = p_brand_id - ORDER BY e.logged_at DESC - LIMIT p_limit - ) t - )); -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/006_water_log_rpcs_fixed.sql b/supabase/migrations/.archived/006_water_log_rpcs_fixed.sql deleted file mode 100644 index 2a713c0..0000000 --- a/supabase/migrations/.archived/006_water_log_rpcs_fixed.sql +++ /dev/null @@ -1,263 +0,0 @@ --- ============================================================================= --- Water Log V1 — Consolidated RPC Repair --- Fixes: gen_salt/crypt calls, schema alignment, overload removal --- Uses fully-qualified pgcrypto: extensions.crypt / extensions.gen_salt --- ============================================================================= - --- Drop all existing water log RPCs (ignore errors if some don't exist) -DROP FUNCTION IF EXISTS public.verify_water_pin(uuid, text); -DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text, text); -DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text); -DROP FUNCTION IF EXISTS public.reset_water_user_pin(uuid); -DROP FUNCTION IF EXISTS public.reset_water_irrigator_pin(uuid); -DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text, text); -DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text); -DROP FUNCTION IF EXISTS public.update_water_irrigator(uuid, text, boolean, text); -DROP FUNCTION IF EXISTS public.update_water_user(uuid, text, boolean, text); -DROP FUNCTION IF EXISTS public.get_water_irrigators(uuid); -DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int); -DROP FUNCTION IF EXISTS public.get_water_entries(uuid); - --- ============================================================================= --- verify_water_pin --- Returns jsonb: {success, user_id, name, role, session_id, lang} or {success:false, error} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text) -RETURNS jsonb -LANGUAGE plpgsql -STATIC -AS $$ -DECLARE - v_user jsonb; - v_session_id uuid; -BEGIN - -- Look up user by PIN (pgcrypto fully-qualified) - SELECT jsonb_build_object( - 'id', id, - 'name', name, - 'role', role, - 'lang', language_preference - ) INTO v_user - FROM public.water_users - WHERE brand_id = p_brand_id - AND active = true - AND role IN ('irrigator', 'water_admin') - AND extensions.crypt(p_pin, pin_hash) = pin_hash; - - IF v_user IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN'); - END IF; - - -- Update last-used timestamp - UPDATE public.water_users - SET last_used_at = now() - WHERE id = (v_user->>'id')::uuid; - - -- Create session - v_session_id := gen_random_uuid(); - INSERT INTO public.water_sessions (id, irrigator_id, expires_at) - VALUES (v_session_id, (v_user->>'id')::uuid, now() + interval '4 hours'); - - RETURN jsonb_build_object( - 'success', true, - 'user_id', v_user->>'id', - 'name', v_user->>'name', - 'role', v_user->>'role', - 'session_id', v_session_id, - 'lang', v_user->>'lang' - ); -END; -$$; - -COMMENT ON FUNCTION public.verify_water_pin IS - 'Verify irrigator/admin PIN, create session, return user info as JSONB.'; - --- ============================================================================= --- create_water_user --- Returns jsonb: {success, user: {id,name,role}, pin} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.create_water_user( - p_brand_id uuid, - p_name text, - p_role text, - p_lang text DEFAULT 'en' -) -RETURNS jsonb -LANGUAGE plpgsql -STATIC -AS $$ -DECLARE - v_brand_id uuid; - v_raw_pin text; - v_user_id uuid; - v_user_role text; -BEGIN - v_brand_id := COALESCE(p_brand_id, '64294306-5f42-463d-a5e8-2ad6c81a96de'::uuid); - v_raw_pin := lpad(floor(random() * 9000 + 1000)::text, 4, '0'); - v_user_role := COALESCE(p_role, 'irrigator'); - - INSERT INTO public.water_users (brand_id, name, pin_hash, role, language_preference, active) - VALUES ( - v_brand_id, - p_name, - extensions.crypt(v_raw_pin, extensions.gen_salt('bf')), - v_user_role, - p_lang, - true - ) - RETURNING id INTO v_user_id; - - RETURN jsonb_build_object( - 'success', true, - 'user', jsonb_build_object('id', v_user_id, 'name', p_name, 'role', v_user_role), - 'pin', v_raw_pin - ); -END; -$$; - -COMMENT ON FUNCTION public.create_water_user IS - 'Create water user, generate PIN, return hashed PIN and plaintext PIN once.'; - --- ============================================================================= --- reset_water_user_pin --- Returns jsonb: {success, user_id, pin} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -STATIC -AS $$ -DECLARE - v_raw_pin text; - v_name text; -BEGIN - v_raw_pin := lpad(floor(random() * 9000 + 1000)::text, 4, '0'); - - UPDATE public.water_users - SET pin_hash = extensions.crypt(v_raw_pin, extensions.gen_salt('bf')) - WHERE id = p_user_id - RETURNING name INTO v_name; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'User not found'); - END IF; - - RETURN jsonb_build_object('success', true, 'user_id', p_user_id, 'pin', v_raw_pin); -END; -$$; - --- ============================================================================= --- submit_water_entry --- Returns jsonb: {success, entry_id} or {success:false, error} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.submit_water_entry( - p_session_id uuid, - p_headgate_id uuid, - p_measurement numeric, - p_unit text, - p_notes text DEFAULT '', - p_submitted_via text DEFAULT 'field' -) -RETURNS jsonb -LANGUAGE plpgsql -STATIC -AS $$ -DECLARE - v_sess record; - v_brand_id uuid; - v_entry_id uuid; -BEGIN - -- Look up session and join to water_users to get brand_id - SELECT s.id, s.irrigator_id, u.brand_id - INTO v_sess, v_brand_id - FROM public.water_sessions s - JOIN public.water_users u ON s.irrigator_id = u.id - WHERE s.id = p_session_id - AND s.expires_at > now(); - - IF NOT FOUND OR v_sess.irrigator_id IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid'); - END IF; - - INSERT INTO public.water_log_entries - (brand_id, headgate_id, irrigator_id, measurement, unit, notes, submitted_via) - VALUES - (v_brand_id, p_headgate_id, v_sess.irrigator_id, p_measurement, p_unit, p_notes, p_submitted_via) - RETURNING id INTO v_entry_id; - - RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id); -END; -$$; - -COMMENT ON FUNCTION public.submit_water_entry IS - 'Submit a water log entry using an active wl_session cookie.'; - --- ============================================================================= --- update_water_user --- Returns jsonb: {success} or {success:false, error} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.update_water_user( - p_user_id uuid, - p_name text, - p_active boolean, - p_lang text DEFAULT 'en' -) -RETURNS jsonb -LANGUAGE plpgsql -STATIC -AS $$ -BEGIN - UPDATE public.water_users - SET name = p_name, active = p_active, language_preference = p_lang - WHERE id = p_user_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'User not found'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ============================================================================= --- get_water_entries --- Returns jsonb: {entries: [...]} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50) -RETURNS jsonb -LANGUAGE plpgsql -STATIC -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('entries', COALESCE( - (SELECT jsonb_agg(e ORDER BY e.logged_at DESC) - FROM ( - SELECT - e.id, - e.headgate_id, - e.irrigator_id, - h.name AS headgate_name, - u.name AS irrigator_name, - e.measurement, - e.unit, - e.notes, - e.submitted_via, - e.logged_at - FROM public.water_log_entries e - JOIN public.water_headgates h ON e.headgate_id = h.id - JOIN public.water_users u ON e.irrigator_id = u.id - WHERE e.brand_id = p_brand_id - ORDER BY e.logged_at DESC - LIMIT p_limit - ) e - ), '[]'::jsonb - )) - ); -END; -$$; - --- ============================================================================= --- Reload PostgREST schema cache so new signatures are visible --- ============================================================================= -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/007_water_log_column_fix.sql b/supabase/migrations/.archived/007_water_log_column_fix.sql deleted file mode 100644 index 92fb907..0000000 --- a/supabase/migrations/.archived/007_water_log_column_fix.sql +++ /dev/null @@ -1,232 +0,0 @@ --- ============================================================================= --- Water Log V1 — Column name alignment + SECURITY DEFINER --- Deployed tables: water_users, water_sessions(user_id), water_log_entries(user_id) --- ============================================================================= - -DROP FUNCTION IF EXISTS public.verify_water_pin(uuid, text); -DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text, text); -DROP FUNCTION IF EXISTS public.reset_water_user_pin(uuid); -DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text, text); -DROP FUNCTION IF EXISTS public.update_water_user(uuid, text, boolean, text); -DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int); - --- ============================================================================= --- verify_water_pin(p_brand_id uuid, p_pin text) --- Returns jsonb: {success, user_id, name, role, session_id, lang} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_user jsonb; - v_session_id uuid; -BEGIN - SELECT jsonb_build_object( - 'id', id, - 'name', name, - 'role', role, - 'lang', language_preference - ) INTO v_user - FROM public.water_users - WHERE active = true - AND role IN ('irrigator', 'water_admin') - AND extensions.crypt(p_pin, pin_hash) = pin_hash; - - IF v_user IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN'); - END IF; - - UPDATE public.water_users - SET last_used_at = now() - WHERE id = (v_user->>'id')::uuid; - - v_session_id := gen_random_uuid(); - INSERT INTO public.water_sessions (id, user_id, role, expires_at) - VALUES (v_session_id, (v_user->>'id')::uuid, v_user->>'role', now() + interval '4 hours'); - - RETURN jsonb_build_object( - 'success', true, - 'user_id', v_user->>'id', - 'name', v_user->>'name', - 'role', v_user->>'role', - 'session_id', v_session_id, - 'lang', v_user->>'lang' - ); -END; -$$; - --- ============================================================================= --- create_water_user(p_brand_id, p_name, p_role, p_lang) --- Returns jsonb: {success, user: {id,name,role}, pin} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.create_water_user( - p_brand_id uuid, - p_name text, - p_role text, - p_lang text DEFAULT 'en' -) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_raw_pin text := lpad(floor(random() * 9000 + 1000)::text, 4, '0'); - v_user_id uuid; - v_user_role text := COALESCE(p_role, 'irrigator'); - v_brand_id uuid := COALESCE(p_brand_id, '64294306-5f42-463d-a5e8-2ad6c81a96de'::uuid); -BEGIN - INSERT INTO public.water_users (brand_id, name, pin_hash, role, language_preference, active) - VALUES ( - v_brand_id, - p_name, - extensions.crypt(v_raw_pin, extensions.gen_salt('bf')), - v_user_role, - p_lang, - true - ) - RETURNING id INTO v_user_id; - - RETURN jsonb_build_object( - 'success', true, - 'user', jsonb_build_object('id', v_user_id, 'name', p_name, 'role', v_user_role), - 'pin', v_raw_pin - ); -END; -$$; - --- ============================================================================= --- reset_water_user_pin(p_user_id) --- Returns jsonb: {success, user_id, pin} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_raw_pin text := lpad(floor(random() * 9000 + 1000)::text, 4, '0'); - v_name text; -BEGIN - UPDATE public.water_users - SET pin_hash = extensions.crypt(v_raw_pin, extensions.gen_salt('bf')) - WHERE id = p_user_id - RETURNING name INTO v_name; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'User not found'); - END IF; - - RETURN jsonb_build_object('success', true, 'user_id', p_user_id, 'pin', v_raw_pin); -END; -$$; - --- ============================================================================= --- submit_water_entry(p_session_id, p_headgate_id, p_measurement, p_unit, p_notes, p_submitted_via) --- Returns jsonb: {success, entry_id} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.submit_water_entry( - p_session_id uuid, - p_headgate_id uuid, - p_measurement numeric, - p_unit text, - p_notes text DEFAULT '', - p_submitted_via text DEFAULT 'field' -) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_user_id uuid; - v_brand_id uuid; - v_entry_id uuid; -BEGIN - SELECT s.user_id, h.brand_id - INTO v_user_id, v_brand_id - FROM public.water_sessions s - JOIN public.water_headgates h ON h.id = p_headgate_id - WHERE s.id = p_session_id - AND s.expires_at > now(); - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid'); - END IF; - - INSERT INTO public.water_log_entries - (brand_id, headgate_id, user_id, measurement, unit, notes, submitted_via) - VALUES - (v_brand_id, p_headgate_id, v_user_id, p_measurement, p_unit, p_notes, p_submitted_via) - RETURNING id INTO v_entry_id; - - RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id); -END; -$$; - --- ============================================================================= --- update_water_user(p_user_id, p_name, p_active, p_lang) --- Returns jsonb: {success} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.update_water_user( - p_user_id uuid, - p_name text, - p_active boolean, - p_lang text DEFAULT 'en' -) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE public.water_users - SET name = p_name, active = p_active, language_preference = p_lang - WHERE id = p_user_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'User not found'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ============================================================================= --- get_water_entries(p_brand_id, p_limit) --- Returns jsonb: {entries: [...]} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('entries', COALESCE( - (SELECT jsonb_agg(e ORDER BY e.logged_at DESC) - FROM ( - SELECT - e.id, - e.headgate_id, - e.user_id, - h.name AS headgate_name, - u.name AS user_name, - e.measurement, - e.unit, - e.notes, - e.submitted_via, - e.logged_at - FROM public.water_log_entries e - JOIN public.water_headgates h ON e.headgate_id = h.id - JOIN public.water_users u ON e.user_id = u.id - WHERE e.brand_id = p_brand_id - ORDER BY e.logged_at DESC - LIMIT p_limit - ) e - ), '[]'::jsonb - )) - ); -END; -$$; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/008_water_log_soft_delete.sql b/supabase/migrations/.archived/008_water_log_soft_delete.sql deleted file mode 100644 index 87d36fe..0000000 --- a/supabase/migrations/.archived/008_water_log_soft_delete.sql +++ /dev/null @@ -1,123 +0,0 @@ --- ============================================================================= --- Water Log V1 — Soft Delete --- Adds deleted_at to water_users and water_headgates --- Creates delete_water_user and delete_water_headgate RPCs --- Existing entries preserved; deleted users/headgates excluded from queries --- ============================================================================= - --- Add deleted_at column to water_users -ALTER TABLE public.water_users - ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; - --- Add deleted_at column to water_headgates -ALTER TABLE public.water_headgates - ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; - --- ============================================================================= --- delete_water_user(p_user_id uuid) --- Soft-deletes a water user (sets deleted_at) so entries are preserved. --- Returns jsonb: {success} or {success:false, error} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.delete_water_user(p_user_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE public.water_users - SET deleted_at = now() - WHERE id = p_user_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'User not found or already deleted'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ============================================================================= --- delete_water_headgate(p_headgate_id uuid) --- Soft-deletes a water headgate (sets deleted_at) so entries are preserved. --- Returns jsonb: {success} or {success:false, error} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.delete_water_headgate(p_headgate_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE public.water_headgates - SET deleted_at = now() - WHERE id = p_headgate_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Headgate not found or already deleted'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ============================================================================= --- Update get_water_users to exclude soft-deleted users --- ============================================================================= -CREATE OR REPLACE FUNCTION public.get_water_users(p_brand_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('users', COALESCE( - (SELECT jsonb_agg( - jsonb_build_object( - 'id', id, - 'name', name, - 'role', role, - 'active', active, - 'language_preference', language_preference, - 'last_used_at', last_used_at, - 'created_at', created_at, - 'deleted_at', deleted_at - ) ORDER BY created_at DESC - ) - FROM public.water_users - WHERE brand_id = p_brand_id AND deleted_at IS NULL - ), '[]'::jsonb - )) - ); -END; -$$; - --- ============================================================================= --- Update get_water_headgates to exclude soft-deleted headgates --- ============================================================================= -CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('headgates', COALESCE( - (SELECT jsonb_agg( - jsonb_build_object( - 'id', id, - 'name', name, - 'active', active, - 'created_at', created_at, - 'deleted_at', deleted_at - ) ORDER BY created_at DESC - ) - FROM public.water_headgates - WHERE brand_id = p_brand_id - AND (NOT p_active_only OR active = true) - AND deleted_at IS NULL - ), '[]'::jsonb - )) - ); -END; -$$; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/009_water_log_entry_edit_delete.sql b/supabase/migrations/.archived/009_water_log_entry_edit_delete.sql deleted file mode 100644 index d858a1d..0000000 --- a/supabase/migrations/.archived/009_water_log_entry_edit_delete.sql +++ /dev/null @@ -1,88 +0,0 @@ --- ============================================================================= --- Water Log V1 — Entry Edit & Delete --- Allows water_admin and platform_admin to edit measurement/notes --- Allows deletion of entries (hard delete, not soft) --- ============================================================================= - --- ============================================================================= --- update_water_entry(p_entry_id uuid, p_measurement numeric, p_notes text) --- Updates measurement and notes only. Preserves user_id, headgate_id, --- logged_at, submitted_via. --- Returns jsonb: {success} or {success:false, error} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.update_water_entry(p_entry_id uuid, p_measurement numeric, p_notes text) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE public.water_log_entries - SET measurement = p_measurement, notes = p_notes - WHERE id = p_entry_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Entry not found'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ============================================================================= --- delete_water_entry(p_entry_id uuid) --- Hard-deletes an entry. Use with caution — no soft-delete for entries. --- Returns jsonb: {success} or {success:false, error} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.delete_water_entry(p_entry_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - DELETE FROM public.water_log_entries WHERE id = p_entry_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Entry not found'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - -NOTIFY pgrst, 'reload schema'; - --- ============================================================================= --- get_water_entry_by_id(p_entry_id uuid) --- Returns a single entry row for the edit page. --- Returns jsonb: {entry: {...}} or {entry: null} --- ============================================================================= -CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('entry', ( - SELECT jsonb_build_object( - 'id', e.id, - 'headgate_id', e.headgate_id, - 'user_id', e.user_id, - 'headgate_name', hg.name, - 'user_name', u.name, - 'measurement', e.measurement, - 'unit', e.unit, - 'notes', e.notes, - 'submitted_via', e.submitted_via, - 'logged_at', e.logged_at - ) - FROM public.water_log_entries e - JOIN public.water_headgates hg ON hg.id = e.headgate_id - JOIN public.water_users u ON u.id = e.user_id - WHERE e.id = p_entry_id - )) - ); -END; -$$; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/010_water_log_unit_types.sql b/supabase/migrations/.archived/010_water_log_unit_types.sql deleted file mode 100644 index 16b893d..0000000 --- a/supabase/migrations/.archived/010_water_log_unit_types.sql +++ /dev/null @@ -1,202 +0,0 @@ --- ============================================================================= --- Water Log V1.1 — Unit Types + Dedicated Edit Pages --- --- Adds `unit` column to water_headgates so each headgate carries its --- preferred measurement unit (CFS, GPM, etc.). This unit pre-selects --- on the field form and is editable via dedicated headgate/user edit pages. --- --- Also updates update_water_entry to support changing unit. --- ============================================================================= - --- ── Add unit to headgates ──────────────────────────────────── -ALTER TABLE public.water_headgates - ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS'; - --- ── Update submit_water_entry to respect headgate's unit ───── --- The field form should use the headgate's default unit. --- Update get_water_entries to include headgate unit. --- Update update_water_entry to accept optional unit change. - -CREATE OR REPLACE FUNCTION public.update_water_entry( - p_entry_id uuid, - p_measurement numeric, - p_notes text, - p_unit text DEFAULT NULL -) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - IF p_unit IS NOT NULL THEN - UPDATE public.water_log_entries - SET measurement = p_measurement, notes = p_notes, unit = p_unit - WHERE id = p_entry_id; - ELSE - UPDATE public.water_log_entries - SET measurement = p_measurement, notes = p_notes - WHERE id = p_entry_id; - END IF; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Entry not found'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- Update get_water_entries to include headgate's default unit (for pre-fill) -CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('entries', COALESCE( - (SELECT jsonb_agg( - jsonb_build_object( - 'id', e.id, - 'headgate_id', e.headgate_id, - 'user_id', e.user_id, - 'headgate_name', hg.name, - 'user_name', u.name, - 'measurement', e.measurement, - 'unit', e.unit, - 'notes', e.notes, - 'submitted_via', e.submitted_via, - 'logged_at', e.logged_at, - 'headgate_unit', hg.unit - ) ORDER BY e.logged_at DESC - ) - FROM public.water_log_entries e - JOIN public.water_headgates hg ON hg.id = e.headgate_id - JOIN public.water_users u ON u.id = e.user_id - WHERE e.brand_id = p_brand_id - ), '[]'::jsonb - )) - ); -END; -$$; - --- Update get_water_entry_by_id to include headgate_unit for pre-fill -CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('entry', ( - SELECT jsonb_build_object( - 'id', e.id, - 'headgate_id', e.headgate_id, - 'user_id', e.user_id, - 'headgate_name', hg.name, - 'user_name', u.name, - 'measurement', e.measurement, - 'unit', e.unit, - 'notes', e.notes, - 'submitted_via', e.submitted_via, - 'logged_at', e.logged_at, - 'headgate_unit', hg.unit - ) - FROM public.water_log_entries e - JOIN public.water_headgates hg ON hg.id = e.headgate_id - JOIN public.water_users u ON u.id = e.user_id - WHERE e.id = p_entry_id - )) - ); -END; -$$; - --- Drop overloads so there's only one version with p_unit defaulting -DROP FUNCTION IF EXISTS public.update_water_headgate(uuid, text, boolean); - -CREATE OR REPLACE FUNCTION public.update_water_headgate( - p_headgate_id uuid, - p_name text, - p_active boolean, - p_unit text DEFAULT NULL -) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - IF p_unit IS NOT NULL THEN - UPDATE public.water_headgates - SET name = p_name, active = p_active, unit = p_unit - WHERE id = p_headgate_id AND deleted_at IS NULL; - ELSE - UPDATE public.water_headgates - SET name = p_name, active = p_active - WHERE id = p_headgate_id AND deleted_at IS NULL; - END IF; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Headgate not found'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- Update create_water_headgate to accept unit -CREATE OR REPLACE FUNCTION public.create_water_headgate(p_brand_id uuid, p_name text, p_unit text DEFAULT 'CFS') -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_headgate_id uuid; - v_result jsonb; -BEGIN - INSERT INTO public.water_headgates (brand_id, name, unit) - VALUES (p_brand_id, p_name, p_unit) - RETURNING id INTO v_headgate_id; - - SELECT jsonb_build_object( - 'id', id, - 'name', name, - 'active', active, - 'unit', unit, - 'created_at', created_at - ) INTO v_result - FROM public.water_headgates WHERE id = v_headgate_id; - - RETURN jsonb_build_object('headgate', v_result); -END; -$$; - --- get_water_headgates admin view should include unit -CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('headgates', COALESCE( - (SELECT jsonb_agg( - jsonb_build_object( - 'id', id, - 'name', name, - 'active', active, - 'unit', unit, - 'created_at', created_at, - 'deleted_at', deleted_at - ) ORDER BY created_at DESC - ) - FROM public.water_headgates - WHERE brand_id = p_brand_id - AND (NOT p_active_only OR active = true) - AND deleted_at IS NULL - ), '[]'::jsonb - )) - ); -END; -$$; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/011_water_log_consolidated_fix.sql b/supabase/migrations/.archived/011_water_log_consolidated_fix.sql deleted file mode 100644 index 59bf622..0000000 --- a/supabase/migrations/.archived/011_water_log_consolidated_fix.sql +++ /dev/null @@ -1,289 +0,0 @@ --- ============================================================================= --- Water Log V1.1 — Unit Types + Soft Delete Fix (consolidated) --- --- If migration 008 was not run, this adds deleted_at columns. --- If migration 010 had issues, this fixes the function overload and adds missing cols. --- Safe to run multiple times — uses ADD COLUMN IF NOT EXISTS and IF EXISTS drops. --- ============================================================================= - --- ── Soft delete columns (if not already present) ───────────────────────────── -ALTER TABLE public.water_users - ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; - -ALTER TABLE public.water_headgates - ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS', - ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; - --- ── Fix update_water_headgate overload ─────────────────────────────────────── --- Drop old 3-param overload so there's only one version (with p_unit) -DROP FUNCTION IF EXISTS public.update_water_headgate(uuid, text, boolean); - -CREATE OR REPLACE FUNCTION public.update_water_headgate( - p_headgate_id uuid, - p_name text, - p_active boolean, - p_unit text DEFAULT NULL -) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - IF p_unit IS NOT NULL THEN - UPDATE public.water_headgates - SET name = p_name, active = p_active, unit = p_unit - WHERE id = p_headgate_id AND deleted_at IS NULL; - ELSE - UPDATE public.water_headgates - SET name = p_name, active = p_active - WHERE id = p_headgate_id AND deleted_at IS NULL; - END IF; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Headgate not found'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── Fix update_water_entry overload ────────────────────────────────────────── --- Drop old 3-param overload so only the 4-param version (p_unit optional) exists -DROP FUNCTION IF EXISTS public.update_water_entry(uuid, numeric, text); - -CREATE OR REPLACE FUNCTION public.update_water_entry( - p_entry_id uuid, - p_measurement numeric, - p_notes text, - p_unit text DEFAULT NULL -) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - IF p_unit IS NOT NULL THEN - UPDATE public.water_log_entries - SET measurement = p_measurement, notes = p_notes, unit = p_unit - WHERE id = p_entry_id; - ELSE - UPDATE public.water_log_entries - SET measurement = p_measurement, notes = p_notes - WHERE id = p_entry_id; - END IF; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Entry not found'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── Create Water Headgate (updated to support p_unit) ──────────────────────── -DROP FUNCTION IF EXISTS public.create_water_headgate(uuid, text, text); - -CREATE OR REPLACE FUNCTION public.create_water_headgate( - p_brand_id uuid, - p_name text, - p_unit text DEFAULT 'CFS' -) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_headgate_id uuid; - v_result jsonb; -BEGIN - INSERT INTO public.water_headgates (brand_id, name, unit) - VALUES (p_brand_id, p_name, p_unit) - RETURNING id INTO v_headgate_id; - - SELECT jsonb_build_object( - 'id', id, - 'name', name, - 'active', active, - 'unit', unit, - 'created_at', created_at - ) INTO v_result - FROM public.water_headgates WHERE id = v_headgate_id; - - RETURN jsonb_build_object('headgate', v_result); -END; -$$; - --- ── Delete Water User (soft delete) ─────────────────────────────────────────── -DROP FUNCTION IF EXISTS public.delete_water_user(uuid); - -CREATE OR REPLACE FUNCTION public.delete_water_user(p_user_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE public.water_users - SET deleted_at = now() - WHERE id = p_user_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'User not found or already deleted'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── Delete Water Headgate (soft delete) ────────────────────────────────────── -DROP FUNCTION IF EXISTS public.delete_water_headgate(uuid); - -CREATE OR REPLACE FUNCTION public.delete_water_headgate(p_headgate_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE public.water_headgates - SET deleted_at = now() - WHERE id = p_headgate_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Headgate not found or already deleted'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── Get Water Users (exclude soft-deleted) ─────────────────────────────────── -DROP FUNCTION IF EXISTS public.get_water_users(uuid); - -CREATE OR REPLACE FUNCTION public.get_water_users(p_brand_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('users', COALESCE( - (SELECT jsonb_agg( - jsonb_build_object( - 'id', id, - 'name', name, - 'role', role, - 'active', active, - 'language_preference', language_preference, - 'last_used_at', last_used_at, - 'created_at', created_at, - 'deleted_at', deleted_at - ) ORDER BY created_at DESC - ) - FROM public.water_users - WHERE brand_id = p_brand_id AND deleted_at IS NULL - ), '[]'::jsonb - )) - ); -END; -$$; - --- ── Get Water Headgates (exclude soft-deleted, include unit) ─────────────────── -DROP FUNCTION IF EXISTS public.get_water_headgates(uuid, boolean); - -CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('headgates', COALESCE( - (SELECT jsonb_agg( - jsonb_build_object( - 'id', id, - 'name', name, - 'active', active, - 'unit', unit, - 'created_at', created_at, - 'deleted_at', deleted_at - ) ORDER BY created_at DESC - ) - FROM public.water_headgates - WHERE brand_id = p_brand_id - AND (NOT p_active_only OR active = true) - AND deleted_at IS NULL - ), '[]'::jsonb - )) - ); -END; -$$; - --- ── Get Water Entries (include headgate_unit) ───────────────────────────────── -DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int); - -CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('entries', COALESCE( - (SELECT jsonb_agg( - jsonb_build_object( - 'id', e.id, - 'headgate_id', e.headgate_id, - 'user_id', e.user_id, - 'headgate_name', hg.name, - 'user_name', u.name, - 'measurement', e.measurement, - 'unit', e.unit, - 'notes', e.notes, - 'submitted_via', e.submitted_via, - 'logged_at', e.logged_at, - 'headgate_unit', hg.unit - ) ORDER BY e.logged_at DESC - ) - FROM public.water_log_entries e - JOIN public.water_headgates hg ON hg.id = e.headgate_id - JOIN public.water_users u ON u.id = e.user_id - WHERE e.brand_id = p_brand_id - ), '[]'::jsonb - )) - ); -END; -$$; - --- ── Get Water Entry By ID (include headgate_unit) ──────────────────────────── -DROP FUNCTION IF EXISTS public.get_water_entry_by_id(uuid); - -CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('entry', ( - SELECT jsonb_build_object( - 'id', e.id, - 'headgate_id', e.headgate_id, - 'user_id', e.user_id, - 'headgate_name', hg.name, - 'user_name', u.name, - 'measurement', e.measurement, - 'unit', e.unit, - 'notes', e.notes, - 'submitted_via', e.submitted_via, - 'logged_at', e.logged_at, - 'headgate_unit', hg.unit - ) - FROM public.water_log_entries e - JOIN public.water_headgates hg ON hg.id = e.headgate_id - JOIN public.water_users u ON u.id = e.user_id - WHERE e.id = p_entry_id - )) - ); -END; -$$; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/012_water_log_display_summary.sql b/supabase/migrations/.archived/012_water_log_display_summary.sql deleted file mode 100644 index 3aaf03d..0000000 --- a/supabase/migrations/.archived/012_water_log_display_summary.sql +++ /dev/null @@ -1,103 +0,0 @@ --- ============================================================================= --- Water Log V1.6 - Display Summary RPC --- Returns headgate latest readings + today's aggregates for Smartsheet/display. --- SECURITY DEFINER so anon key can call it (same as other water log RPCs). --- ============================================================================= - -CREATE OR REPLACE FUNCTION public.get_water_display_summary(p_brand_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN ( - WITH headgate_data AS ( - SELECT - hg.id, - hg.name, - COALESCE(hg.unit, 'CFS') as unit, - ( - SELECT jsonb_build_object( - 'measurement', le.measurement, - 'user_name', u.name, - 'logged_at', le.logged_at - ) - FROM public.water_log_entries le - JOIN public.water_users u ON u.id = le.user_id - WHERE le.headgate_id = hg.id - ORDER BY le.logged_at DESC - LIMIT 1 - ) as latest_entry, - ( - SELECT le.logged_at - FROM public.water_log_entries le - WHERE le.headgate_id = hg.id - ORDER BY le.logged_at DESC - LIMIT 1 - ) as last_logged_at, - ( - SELECT FLOOR(EXTRACT(EPOCH FROM (now() - le.logged_at)) / 60)::int - FROM public.water_log_entries le - WHERE le.headgate_id = hg.id - ORDER BY le.logged_at DESC - LIMIT 1 - ) as minutes_ago - FROM public.water_headgates hg - WHERE hg.brand_id = p_brand_id - AND hg.active = true - AND hg.deleted_at IS NULL - ), - today_stats AS ( - SELECT - count(*) as cnt, - COALESCE(sum(measurement), 0) as total - FROM public.water_log_entries - WHERE brand_id = p_brand_id AND logged_at::date = CURRENT_DATE - ), - entries_ordered AS ( - SELECT e.logged_at, e.headgate_id, e.user_id, e.measurement, e.unit, e.notes, e.submitted_via - FROM public.water_log_entries e - WHERE e.brand_id = p_brand_id - ORDER BY e.logged_at DESC - LIMIT 20 - ), - recent_entries_agg AS ( - SELECT COALESCE(jsonb_agg( - jsonb_build_object( - 'logged_at', e.logged_at, - 'headgate_name', hg.name, - 'user_name', u.name, - 'user_role', u.role, - 'measurement', e.measurement, - 'unit', e.unit, - 'notes', e.notes, - 'submitted_via', e.submitted_via - ) - ), '[]'::jsonb) as data - FROM entries_ordered e - JOIN public.water_headgates hg ON hg.id = e.headgate_id - JOIN public.water_users u ON u.id = e.user_id - ) - SELECT jsonb_build_object( - 'headgates', ( - SELECT COALESCE(jsonb_agg( - jsonb_build_object( - 'id', hd.id, - 'name', hd.name, - 'unit', hd.unit, - 'latest_entry', hd.latest_entry, - 'last_logged_at', hd.last_logged_at, - 'minutes_ago', hd.minutes_ago - ) - ), '[]'::jsonb) - FROM headgate_data hd - ), - 'today_count', (SELECT cnt FROM today_stats), - 'today_total', (SELECT total FROM today_stats), - 'recent_entries', (SELECT data FROM recent_entries_agg) - ) - ); -END; -$$; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/013_water_log_seed_admin.sql b/supabase/migrations/.archived/013_water_log_seed_admin.sql deleted file mode 100644 index 07fed7d..0000000 --- a/supabase/migrations/.archived/013_water_log_seed_admin.sql +++ /dev/null @@ -1,13 +0,0 @@ --- ============================================================================= --- Water Log V1.6 - Seed Water Admin User --- ============================================================================= - -INSERT INTO public.water_users (brand_id, name, pin_hash, role, active, language_preference) -VALUES ( - '64294306-5f42-463d-a5e8-2ad6c81a96de', - 'Test Water Admin', - extensions.crypt('1234', gen_salt('bf')), - 'water_admin', - true, - 'en' -); \ No newline at end of file diff --git a/supabase/migrations/.archived/014_water_sessions_rls.sql b/supabase/migrations/.archived/014_water_sessions_rls.sql deleted file mode 100644 index 93f3d72..0000000 --- a/supabase/migrations/.archived/014_water_sessions_rls.sql +++ /dev/null @@ -1,9 +0,0 @@ --- ============================================================================= --- Water Log V1.6 - Fix water_sessions RLS --- Add SELECT policy so getWaterAdminSession can read sessions with anon key --- ============================================================================= - -DROP POLICY IF EXISTS "Sessions readable by all" ON public.water_sessions; -CREATE POLICY "Sessions readable by all" ON public.water_sessions FOR SELECT TO anon USING (true); - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/015_water_users_security_hardening.sql b/supabase/migrations/.archived/015_water_users_security_hardening.sql deleted file mode 100644 index 26ebed6..0000000 --- a/supabase/migrations/.archived/015_water_users_security_hardening.sql +++ /dev/null @@ -1,90 +0,0 @@ --- ============================================================================= --- Water Log V1.6 - Security Hardening: block direct water_users access --- --- Problem: pin_hash can be read via direct REST SELECT with anon key. --- Fix: Remove direct SELECT policies. All reads go through SECURITY DEFINER --- RPCs which run as the supabase postgres user and bypass RLS. --- --- Changes: --- 1. DROP "Users readable by all" policy (was added to fix getWaterAdminSession) --- 2. Create get_water_user_by_id(p_user_id) RPC for language lookup (SECURITY DEFINER) --- 3. Create get_water_admin_session() RPC (SECURITY DEFINER) replacing direct --- water_sessions + water_users JOIN in field.ts getWaterAdminSession --- 4. Update field.ts to use RPCs instead of direct REST calls --- 5. Supabase will continue to route all water_users writes through the existing --- SECURITY DEFINER RPCs (create_water_user, update_water_user, etc.) --- ============================================================================= - --- Step 1: Remove direct SELECT policy from water_users -DROP POLICY IF EXISTS "Users readable by all" ON public.water_users; - --- Step 2: Create a SECURITY DEFINER RPC to get a single user's language preference --- This replaces the direct REST call in field.ts line 83 --- (runs as supabase postgres user, bypasses RLS) -CREATE OR REPLACE FUNCTION public.get_water_user_by_id(p_user_id uuid) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result jsonb; -BEGIN - SELECT jsonb_build_object( - 'language_preference', language_preference - ) INTO v_result - FROM public.water_users - WHERE id = p_user_id AND deleted_at IS NULL; - - RETURN v_result; -END; -$$; - --- Step 3: Create get_water_admin_session() RPC (SECURITY DEFINER) --- Reads the wl_session cookie and returns the associated user's info. --- Replaces the direct water_sessions + water_users JOIN in getWaterAdminSession. --- Runs as supabase postgres user, bypasses RLS on both water_sessions and water_users. -CREATE OR REPLACE FUNCTION public.get_water_admin_session() -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_session_id text; - v_result jsonb; -BEGIN - -- Read wl_session from request cookies (set by verify_water_pin) - -- In SECURITY DEFINER context, we can't access HTTP cookies directly. - -- The session_id is passed from the caller (Next.js server action). - -- Instead, create a variant that accepts session_id as a parameter. - RETURN null; -- placeholder — actual implementation uses p_session_id below -END; -$$; - --- Drop and recreate with proper signature (accepts p_session_id) -DROP FUNCTION IF EXISTS public.get_water_admin_session(); - -CREATE OR REPLACE FUNCTION public.get_water_admin_session(p_session_id text) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result jsonb; -BEGIN - SELECT jsonb_build_object( - 'user_id', s.user_id, - 'name', u.name, - 'role', u.role - ) INTO v_result - FROM public.water_sessions s - JOIN public.water_users u ON u.id = s.user_id - WHERE s.id = p_session_id::uuid - AND s.expires_at > now() - AND u.role = 'water_admin' - AND u.deleted_at IS NULL; - - RETURN v_result; -END; -$$; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/016_communication_center.sql b/supabase/migrations/.archived/016_communication_center.sql deleted file mode 100644 index 6c528ad..0000000 --- a/supabase/migrations/.archived/016_communication_center.sql +++ /dev/null @@ -1,662 +0,0 @@ --- ============================================================================= --- Communication Center V1 — Core Schema --- Fully idempotent: safe to re-run after partial failure or interrupted deploy --- --- Cross-brand platform module: supports Tuxedo Corn, Indian River Direct, and --- future brands via brand_id separation and RLS. --- --- Design principles: --- - Operational messaging first (stop notifications, pickup reminders, etc.) --- - Audience rules owned by campaigns (no separate reusable audience table) --- - Environment-based provider config (Resend API key in env, not in DB) --- - Event-driven future-ready (event_type / event_id on message_logs) --- - No live email/SMS sending until explicitly enabled after testing --- - Customer data lives in orders table — no separate customers table --- ============================================================================= - -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- ── Tables (CREATE TABLE IF NOT EXISTS is already idempotent) ──────────── - -CREATE TABLE IF NOT EXISTS public.communication_settings ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - default_sender_email TEXT, - default_sender_name TEXT, - reply_to_email TEXT, - email_provider TEXT NOT NULL DEFAULT 'resend', - email_footer_html TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE(brand_id) -); - -CREATE TABLE IF NOT EXISTS public.communication_templates ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - name TEXT NOT NULL, - subject TEXT NOT NULL, - body_text TEXT NOT NULL DEFAULT '', - body_html TEXT, - template_type TEXT NOT NULL, - campaign_type TEXT, - created_by UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS public.communication_campaigns ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - name TEXT NOT NULL, - subject TEXT, - body_text TEXT, - body_html TEXT, - template_id UUID REFERENCES communication_templates(id) ON DELETE SET NULL, - campaign_type TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'draft', - audience_rules JSONB NOT NULL DEFAULT '{}', - scheduled_at TIMESTAMPTZ, - sent_at TIMESTAMPTZ, - created_by UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS public.communication_message_logs ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - campaign_id UUID REFERENCES communication_campaigns(id) ON DELETE SET NULL, - customer_id UUID, - customer_email TEXT, - delivery_method TEXT NOT NULL, - subject TEXT, - body_preview TEXT, - status TEXT NOT NULL, - sent_at TIMESTAMPTZ, - error_message TEXT, - event_type TEXT, - event_id UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS public.customer_communication_preferences ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - customer_id UUID NOT NULL, - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - email_opt_in BOOLEAN NOT NULL DEFAULT true, - sms_opt_in BOOLEAN NOT NULL DEFAULT false, - email_opt_in_at TIMESTAMPTZ, - sms_opt_in_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE(customer_id, brand_id) -); - --- ── Indexes (CREATE INDEX IF NOT EXISTS is already idempotent) ──────────── - -CREATE INDEX IF NOT EXISTS idx_communication_settings_brand - ON public.communication_settings(brand_id); -CREATE INDEX IF NOT EXISTS idx_communication_templates_brand - ON public.communication_templates(brand_id); -CREATE INDEX IF NOT EXISTS idx_communication_campaigns_brand_status - ON public.communication_campaigns(brand_id, status); -CREATE INDEX IF NOT EXISTS idx_communication_campaigns_brand_type - ON public.communication_campaigns(brand_id, campaign_type); -CREATE INDEX IF NOT EXISTS idx_communication_message_logs_brand_sent - ON public.communication_message_logs(brand_id, sent_at DESC); -CREATE INDEX IF NOT EXISTS idx_communication_message_logs_campaign - ON public.communication_message_logs(campaign_id); -CREATE INDEX IF NOT EXISTS idx_communication_message_logs_customer - ON public.communication_message_logs(customer_id); -CREATE INDEX IF NOT EXISTS idx_communication_message_logs_event - ON public.communication_message_logs(event_type, event_id) WHERE event_type IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_customer_comm_prefs_customer_brand - ON public.customer_communication_preferences(customer_id, brand_id); - --- ── RLS — idempotent (ALTER TABLE ENABLE is safe to re-run) ──────────────── - -ALTER TABLE public.communication_settings ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.communication_templates ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.communication_campaigns ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.communication_message_logs ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.customer_communication_preferences ENABLE ROW LEVEL SECURITY; - --- ── Policies (DROP IF EXISTS before each CREATE makes them idempotent) ──── - -DROP POLICY IF EXISTS "Platform admin can read communication_settings" - ON public.communication_settings; -DROP POLICY IF EXISTS "Brand admin can read own brand communication_settings" - ON public.communication_settings; -CREATE POLICY "Platform admin can read communication_settings" - ON public.communication_settings FOR SELECT TO authenticated - USING (EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - )); -CREATE POLICY "Brand admin can read own brand communication_settings" - ON public.communication_settings FOR SELECT TO authenticated - USING (brand_id IN ( - SELECT brand_id FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'brand_admin' - )); - -DROP POLICY IF EXISTS "Platform admin can read communication_templates" - ON public.communication_templates; -DROP POLICY IF EXISTS "Brand admin can read own brand communication_templates" - ON public.communication_templates; -CREATE POLICY "Platform admin can read communication_templates" - ON public.communication_templates FOR SELECT TO authenticated - USING (EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - )); -CREATE POLICY "Brand admin can read own brand communication_templates" - ON public.communication_templates FOR SELECT TO authenticated - USING (brand_id IN ( - SELECT brand_id FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'brand_admin' - )); - -DROP POLICY IF EXISTS "Platform admin can read communication_campaigns" - ON public.communication_campaigns; -DROP POLICY IF EXISTS "Brand admin can read own brand communication_campaigns" - ON public.communication_campaigns; -CREATE POLICY "Platform admin can read communication_campaigns" - ON public.communication_campaigns FOR SELECT TO authenticated - USING (EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - )); -CREATE POLICY "Brand admin can read own brand communication_campaigns" - ON public.communication_campaigns FOR SELECT TO authenticated - USING (brand_id IN ( - SELECT brand_id FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'brand_admin' - )); - -DROP POLICY IF EXISTS "Platform admin can read communication_message_logs" - ON public.communication_message_logs; -DROP POLICY IF EXISTS "Brand admin can read own brand communication_message_logs" - ON public.communication_message_logs; -CREATE POLICY "Platform admin can read communication_message_logs" - ON public.communication_message_logs FOR SELECT TO authenticated - USING (EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - )); -CREATE POLICY "Brand admin can read own brand communication_message_logs" - ON public.communication_message_logs FOR SELECT TO authenticated - USING (brand_id IN ( - SELECT brand_id FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'brand_admin' - )); - -DROP POLICY IF EXISTS "Anyone can read customer_communication_preferences" - ON public.customer_communication_preferences; -DROP POLICY IF EXISTS "Platform admin can read customer_communication_preferences" - ON public.customer_communication_preferences; -CREATE POLICY "Anyone can read customer_communication_preferences" - ON public.customer_communication_preferences FOR SELECT TO anon USING (true); -CREATE POLICY "Platform admin can read customer_communication_preferences" - ON public.customer_communication_preferences FOR SELECT TO authenticated - USING (EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - )); - --- ── SECURITY DEFINER RPCs ───────────────────────────────────────────────── - -DROP FUNCTION IF EXISTS public.upsert_communication_settings(UUID, TEXT, TEXT, TEXT, TEXT, TEXT); -CREATE OR REPLACE FUNCTION public.upsert_communication_settings( - p_brand_id UUID, - p_sender_email TEXT, - p_sender_name TEXT, - p_reply_to_email TEXT, - p_provider TEXT, - p_footer_html TEXT -) -RETURNS communication_settings -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE v_result communication_settings; -BEGIN - INSERT INTO public.communication_settings - (brand_id, default_sender_email, default_sender_name, reply_to_email, email_provider, email_footer_html) - VALUES (p_brand_id, p_sender_email, p_sender_name, p_reply_to_email, p_provider, p_footer_html) - ON CONFLICT (brand_id) DO UPDATE SET - default_sender_email = EXCLUDED.default_sender_email, - default_sender_name = EXCLUDED.default_sender_name, - reply_to_email = EXCLUDED.reply_to_email, - email_provider = EXCLUDED.email_provider, - email_footer_html = EXCLUDED.email_footer_html, - updated_at = now() - RETURNING * INTO v_result; - RETURN v_result; -END; -$$; - -DROP FUNCTION IF EXISTS public.upsert_communication_template(UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, UUID); -CREATE OR REPLACE FUNCTION public.upsert_communication_template( - p_id UUID, - p_brand_id UUID, - p_name TEXT, - p_subject TEXT, - p_body_text TEXT, - p_body_html TEXT, - p_template_type TEXT, - p_campaign_type TEXT, - p_created_by UUID -) -RETURNS communication_templates -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result communication_templates; - v_id UUID; -BEGIN - v_id := coalesce(p_id, uuid_generate_v4()); - INSERT INTO public.communication_templates - (id, brand_id, name, subject, body_text, body_html, template_type, campaign_type, created_by) - VALUES (v_id, p_brand_id, p_name, p_subject, p_body_text, p_body_html, p_template_type, p_campaign_type, p_created_by) - ON CONFLICT (id) DO UPDATE SET - name = EXCLUDED.name, - subject = EXCLUDED.subject, - body_text = EXCLUDED.body_text, - body_html = EXCLUDED.body_html, - template_type = EXCLUDED.template_type, - campaign_type = EXCLUDED.campaign_type, - updated_at = now() - RETURNING * INTO v_result; - RETURN v_result; -END; -$$; - -DROP FUNCTION IF EXISTS public.upsert_communication_campaign(UUID, UUID, TEXT, TEXT, TEXT, TEXT, UUID, TEXT, TEXT, JSONB, TIMESTAMPTZ, UUID); -CREATE OR REPLACE FUNCTION public.upsert_communication_campaign( - p_id UUID, - p_brand_id UUID, - p_name TEXT, - p_subject TEXT, - p_body_text TEXT, - p_body_html TEXT, - p_template_id UUID, - p_campaign_type TEXT, - p_status TEXT, - p_audience_rules JSONB, - p_scheduled_at TIMESTAMPTZ, - p_created_by UUID -) -RETURNS communication_campaigns -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result communication_campaigns; - v_id UUID; -BEGIN - v_id := coalesce(p_id, uuid_generate_v4()); - INSERT INTO public.communication_campaigns - (id, brand_id, name, subject, body_text, body_html, template_id, campaign_type, status, audience_rules, scheduled_at, created_by) - VALUES (v_id, p_brand_id, p_name, p_subject, p_body_text, p_body_html, p_template_id, p_campaign_type, p_status, p_audience_rules, p_scheduled_at, p_created_by) - ON CONFLICT (id) DO UPDATE SET - name = EXCLUDED.name, - subject = EXCLUDED.subject, - body_text = EXCLUDED.body_text, - body_html = EXCLUDED.body_html, - template_id = EXCLUDED.template_id, - campaign_type = EXCLUDED.campaign_type, - status = EXCLUDED.status, - audience_rules = EXCLUDED.audience_rules, - scheduled_at = EXCLUDED.scheduled_at, - updated_at = now() - WHERE communication_campaigns.brand_id = p_brand_id - RETURNING * INTO v_result; - RETURN v_result; -END; -$$; - -DROP FUNCTION IF EXISTS public.log_communication_messages(JSONB); -CREATE OR REPLACE FUNCTION public.log_communication_messages(p_entries JSONB) -RETURNS integer -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_entry JSONB; - v_inserted_count integer := 0; -BEGIN - FOR v_entry IN SELECT * FROM jsonb_array_elements(p_entries) - LOOP - INSERT INTO public.communication_message_logs - (brand_id, campaign_id, customer_id, customer_email, delivery_method, - subject, body_preview, status, sent_at, error_message) - VALUES ( - (v_entry->>'brand_id')::UUID, - nullif(v_entry->>'campaign_id', '')::UUID, - nullif(v_entry->>'customer_id', '')::UUID, - v_entry->>'customer_email', - v_entry->>'delivery_method', - v_entry->>'subject', - left(v_entry->>'body_preview', 500), - coalesce(v_entry->>'status', 'queued'), - now(), - v_entry->>'error_message' - ); - v_inserted_count := v_inserted_count + 1; - END LOOP; - RETURN v_inserted_count; -END; -$$; - -DROP FUNCTION IF EXISTS public.opt_out_customer(UUID, UUID, TEXT); -CREATE OR REPLACE FUNCTION public.opt_out_customer( - p_customer_id UUID, - p_brand_id UUID, - p_method TEXT -) -RETURNS customer_communication_preferences -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE v_result customer_communication_preferences; -BEGIN - INSERT INTO public.customer_communication_preferences - (customer_id, brand_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at) - VALUES ( - p_customer_id, p_brand_id, - CASE WHEN p_method = 'email' THEN false ELSE true END, - CASE WHEN p_method = 'sms' THEN false ELSE true END, - CASE WHEN p_method = 'email' THEN now() ELSE NULL END, - CASE WHEN p_method = 'sms' THEN now() ELSE NULL END - ) - ON CONFLICT (customer_id, brand_id) DO UPDATE SET - email_opt_in = CASE WHEN p_method = 'email' THEN false ELSE customer_communication_preferences.email_opt_in END, - sms_opt_in = CASE WHEN p_method = 'sms' THEN false ELSE customer_communication_preferences.sms_opt_in END, - email_opt_in_at = CASE WHEN p_method = 'email' THEN now() ELSE email_opt_in_at END, - sms_opt_in_at = CASE WHEN p_method = 'sms' THEN now() ELSE sms_opt_in_at END, - updated_at = now() - RETURNING * INTO v_result; - RETURN v_result; -END; -$$; - -DROP FUNCTION IF EXISTS public.preview_campaign_audience(UUID, JSONB); -CREATE OR REPLACE FUNCTION public.preview_campaign_audience( - p_brand_id UUID, - p_audience_rules JSONB -) -RETURNS jsonb -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_target TEXT; - v_stop_id UUID; - v_date_from TIMESTAMPTZ; - v_date_to TIMESTAMPTZ; - v_city TEXT; - v_order_hist TEXT; - v_days_back INTEGER; - v_product_id UUID; - v_count INTEGER; - v_customers JSONB; -BEGIN - v_target := p_audience_rules->>'target'; - - -- Stop-based targeting - IF v_target = 'stop' THEN - v_stop_id := nullif(p_audience_rules->>'stop_id', '')::UUID; - v_date_from := nullif(p_audience_rules->>'date_from', '')::TIMESTAMPTZ; - v_date_to := nullif(p_audience_rules->>'date_to', '')::TIMESTAMPTZ; - SELECT COUNT(DISTINCT o.customer_id), - jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name) - INTO v_count, v_customers - FROM orders o - JOIN stops s ON s.id = o.stop_id - LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id - WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled') - AND (v_stop_id IS NULL OR o.stop_id = v_stop_id) - AND (v_date_from IS NULL OR o.created_at >= v_date_from) - AND (v_date_to IS NULL OR o.created_at <= v_date_to) - AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true); - - -- ZIP/city targeting (customer ZIP not in orders — city filter only) - ELSIF v_target = 'zip_code' THEN - v_city := p_audience_rules->>'city'; - SELECT COUNT(DISTINCT o.customer_id), - jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name) - INTO v_count, v_customers - FROM orders o - JOIN stops s ON s.id = o.stop_id - LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id - WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled') - AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true) - AND (v_city IS NULL OR true); -- city filter placeholder; ZIP requires customer_zip column in orders - - -- Customer history targeting - ELSIF v_target = 'customer_history' THEN - v_order_hist := p_audience_rules->>'order_history'; - v_days_back := (p_audience_rules->>'days_back')::INTEGER; - - IF v_order_hist = 'first_order' THEN - WITH first_orders AS ( - SELECT o.customer_id, MIN(o.created_at) - FROM orders o JOIN stops s ON s.id = o.stop_id - WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled') - AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL) - GROUP BY o.customer_id HAVING COUNT(*) = 1 - ) - SELECT COUNT(*), - jsonb_agg(DISTINCT jsonb_build_object('id', fo.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name) - INTO v_count, v_customers - FROM first_orders fo - JOIN orders o ON o.customer_id = fo.customer_id - LEFT JOIN customer_communication_preferences cp ON cp.customer_id = fo.customer_id AND cp.brand_id = p_brand_id - WHERE (cp.email_opt_in IS NULL OR cp.email_opt_in = true); - - ELSIF v_order_hist = 'repeat' THEN - WITH repeat_customer_ids AS ( - SELECT o.customer_id - FROM orders o JOIN stops s ON s.id = o.stop_id - WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled') - AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL) - GROUP BY o.customer_id HAVING COUNT(*) > 1 - ) - SELECT COUNT(*), - jsonb_agg(DISTINCT jsonb_build_object('id', rc.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name) - INTO v_count, v_customers - FROM repeat_customer_ids rc - JOIN orders o ON o.customer_id = rc.customer_id - LEFT JOIN customer_communication_preferences cp ON cp.customer_id = rc.customer_id AND cp.brand_id = p_brand_id - WHERE (cp.email_opt_in IS NULL OR cp.email_opt_in = true); - - ELSE - SELECT COUNT(DISTINCT o.customer_id), - jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name) - INTO v_count, v_customers - FROM orders o - JOIN stops s ON s.id = o.stop_id - LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id - WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled') - AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL) - AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true); - END IF; - - -- Product-based targeting - ELSIF v_target = 'product' THEN - v_product_id := nullif(p_audience_rules->>'product_id', '')::UUID; - SELECT COUNT(DISTINCT o.customer_id), - jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name) - INTO v_count, v_customers - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - JOIN stops s ON s.id = o.stop_id - LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id - WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled') - AND (v_product_id IS NULL OR oi.product_id = v_product_id) - AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true); - - -- Explicit customer ID list - ELSIF v_target = 'customer_ids' THEN - SELECT COUNT(*), - jsonb_agg(jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name) - INTO v_count, v_customers - FROM orders o - LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id - WHERE o.customer_id IN ( - SELECT jsonb_array_elements_text(p_audience_rules->'customer_ids')::UUID - ) - AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true); - - ELSE - -- all_customers or unknown - SELECT COUNT(DISTINCT o.customer_id), - jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name) - INTO v_count, v_customers - FROM orders o - JOIN stops s ON s.id = o.stop_id - LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id - WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled') - AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true); - END IF; - - -- Cap sample at 20 - SELECT jsonb_agg(x ORDER BY x->>'name') INTO v_customers - FROM (SELECT * FROM jsonb_array_elements(coalesce(v_customers, '[]'::jsonb)) LIMIT 20) t(x); - - RETURN jsonb_build_object('count', v_count, 'sample_customers', v_customers); -END; -$$; - -DROP FUNCTION IF EXISTS public.send_campaign(UUID); -CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID) -RETURNS jsonb -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_campaign communication_campaigns; - v_settings communication_settings; - v_audience JSONB; - v_entries JSONB := '[]'::JSONB; - v_customer JSONB; - v_count INTEGER := 0; -BEGIN - SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id; - IF NOT FOUND THEN RETURN jsonb_build_object('success', false, 'error', 'Campaign not found'); END IF; - SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id; - IF NOT FOUND THEN RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand'); END IF; - v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules); - FOR v_customer IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb)) - LOOP - v_entries := v_entries || jsonb_build_array(jsonb_build_object( - 'brand_id', v_campaign.brand_id, 'campaign_id', p_campaign_id, - 'customer_id', v_customer->>'id', 'customer_email', v_customer->>'email', - 'delivery_method', 'email', 'subject', v_campaign.subject, - 'body_preview', left(v_campaign.body_text, 500), 'status', 'queued' - )); - v_count := v_count + 1; - END LOOP; - PERFORM log_communication_messages(v_entries); - UPDATE communication_campaigns SET status = 'sent', sent_at = now(), updated_at = now() - WHERE id = p_campaign_id; - RETURN jsonb_build_object('success', true, 'messages_logged', v_count); -END; -$$; - -DROP FUNCTION IF EXISTS public.get_communication_campaigns(UUID); -CREATE OR REPLACE FUNCTION public.get_communication_campaigns(p_brand_id UUID) -RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE v_campaigns JSONB; -BEGIN - IF p_brand_id IS NULL THEN - SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_campaigns FROM communication_campaigns c; - ELSE - SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_campaigns - FROM communication_campaigns c WHERE c.brand_id = p_brand_id; - END IF; - RETURN jsonb_build_object('campaigns', v_campaigns); -END; -$$; - -DROP FUNCTION IF EXISTS public.get_communication_campaign_by_id(UUID); -CREATE OR REPLACE FUNCTION public.get_communication_campaign_by_id(p_campaign_id UUID) -RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE v_campaign communication_campaigns%ROWTYPE; -BEGIN - SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id; - IF NOT FOUND THEN RETURN jsonb_build_object('campaign', NULL); END IF; - RETURN jsonb_build_object('campaign', row_to_json(v_campaign)); -END; -$$; - -DROP FUNCTION IF EXISTS public.get_communication_templates(UUID); -CREATE OR REPLACE FUNCTION public.get_communication_templates(p_brand_id UUID) -RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE v_templates JSONB; -BEGIN - IF p_brand_id IS NULL THEN - SELECT COALESCE(jsonb_agg(t ORDER BY t.name), '[]'::JSONB) INTO v_templates FROM communication_templates t; - ELSE - SELECT COALESCE(jsonb_agg(t ORDER BY t.name), '[]'::JSONB) INTO v_templates - FROM communication_templates t WHERE t.brand_id = p_brand_id; - END IF; - RETURN jsonb_build_object('templates', v_templates); -END; -$$; - -DROP FUNCTION IF EXISTS public.get_communication_settings(UUID); -CREATE OR REPLACE FUNCTION public.get_communication_settings(p_brand_id UUID) -RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE v_settings communication_settings%ROWTYPE; -BEGIN - SELECT * INTO v_settings FROM communication_settings WHERE brand_id = p_brand_id; - IF NOT FOUND THEN RETURN jsonb_build_object('settings', NULL); END IF; - RETURN jsonb_build_object('settings', row_to_json(v_settings)); -END; -$$; - -DROP FUNCTION IF EXISTS public.get_message_logs(UUID, UUID, TEXT, INTEGER); -CREATE OR REPLACE FUNCTION public.get_message_logs( - p_brand_id UUID, - p_campaign_id UUID DEFAULT NULL, - p_status TEXT DEFAULT NULL, - p_limit INTEGER DEFAULT 100 -) -RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE v_logs JSONB; -BEGIN - SELECT COALESCE(jsonb_agg(l ORDER BY l.created_at DESC), '[]'::JSONB) INTO v_logs - FROM ( - SELECT l.* FROM communication_message_logs l - WHERE (p_brand_id IS NULL OR l.brand_id = p_brand_id) - AND (p_campaign_id IS NULL OR l.campaign_id = p_campaign_id) - AND (p_status IS NULL OR l.status = p_status) - ORDER BY l.created_at DESC LIMIT p_limit - ) l; - RETURN jsonb_build_object('logs', v_logs); -END; -$$; - -DROP FUNCTION IF EXISTS public.delete_communication_campaign(UUID); -CREATE OR REPLACE FUNCTION public.delete_communication_campaign(p_campaign_id UUID) -RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE communication_campaigns SET status = 'canceled', updated_at = now() - WHERE id = p_campaign_id; - RETURN FOUND; -END; -$$; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/017_communication_contacts.sql b/supabase/migrations/.archived/017_communication_contacts.sql deleted file mode 100644 index 9e3b165..0000000 --- a/supabase/migrations/.archived/017_communication_contacts.sql +++ /dev/null @@ -1,878 +0,0 @@ --- ============================================================================= --- Communication Center V1.1 — Contacts + Import Foundation --- Fully idempotent: safe to re-run after partial failure --- Additive over V1 (migration 016): --- - Adds communication_contacts table --- - Adds DB trigger to auto-create contacts from orders --- - Adds new RPCs for contact CRUD, import, and opt-out --- - Rewrites preview_campaign_audience to use communication_contacts as primary --- - Updates send_campaign to resolve through communication_contacts --- - Updates opt_out_customer to also set unsubscribed_at on contacts --- ============================================================================= - -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- ── Table ──────────────────────────────────────────────────────────────────── --- Uses CREATE TABLE IF NOT EXISTS so this is safe to re-run — no data loss. - -CREATE TABLE IF NOT EXISTS public.communication_contacts ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - email TEXT, - phone TEXT, - first_name TEXT, - last_name TEXT, - full_name TEXT, - source TEXT NOT NULL, -- 'order' | 'import' | 'manual' | 'admin' - external_id TEXT, - customer_id UUID, - email_opt_in BOOLEAN NOT NULL DEFAULT true, - sms_opt_in BOOLEAN NOT NULL DEFAULT false, - email_opt_in_at TIMESTAMPTZ, - sms_opt_in_at TIMESTAMPTZ, - unsubscribed_at TIMESTAMPTZ, - tags TEXT[] DEFAULT '{}', - metadata JSONB DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT chk_has_email_or_phone CHECK (email IS NOT NULL OR phone IS NOT NULL) -); - --- ── Indexes ───────────────────────────────────────────────────────────────── - -CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_brand_email_unique - ON public.communication_contacts(brand_id, email) - WHERE email IS NOT NULL; - -CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_brand_phone_unique - ON public.communication_contacts(brand_id, phone) - WHERE phone IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_communication_contacts_brand - ON public.communication_contacts(brand_id); - -CREATE INDEX IF NOT EXISTS idx_communication_contacts_customer - ON public.communication_contacts(customer_id) - WHERE customer_id IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_communication_contacts_unsubscribed - ON public.communication_contacts(brand_id, unsubscribed_at) - WHERE unsubscribed_at IS NULL; - -CREATE INDEX IF NOT EXISTS idx_communication_contacts_source - ON public.communication_contacts(brand_id, source); - --- ── RLS ───────────────────────────────────────────────────────────────────── - -ALTER TABLE public.communication_contacts ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Platform admin can read communication_contacts" - ON public.communication_contacts; -CREATE POLICY "Platform admin can read communication_contacts" - ON public.communication_contacts FOR SELECT TO authenticated - USING (EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - )); - -DROP POLICY IF EXISTS "Brand admin can read own brand communication_contacts" - ON public.communication_contacts; -CREATE POLICY "Brand admin can read own brand communication_contacts" - ON public.communication_contacts FOR SELECT TO authenticated - USING (brand_id IN ( - SELECT brand_id FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'brand_admin' - )); - --- ── SECURITY DEFINER RPCs ──────────────────────────────────────────────────── - --- 1. Trigger function for auto-creating contact from order --- PostgreSQL trigger functions MUST use RETURNS TRIGGER and access NEW/OLD directly. -DROP FUNCTION IF EXISTS public.upsert_contact_from_order(); -CREATE OR REPLACE FUNCTION public.upsert_contact_from_order() -RETURNS TRIGGER -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_brand_id UUID; -BEGIN - -- Skip if no email (can't upsert without a key) - IF NEW.customer_email IS NULL OR NEW.customer_email = '' THEN - RETURN NEW; - END IF; - - -- Resolve brand_id from the stop - SELECT brand_id INTO v_brand_id FROM public.stops WHERE id = NEW.stop_id; - IF NOT FOUND THEN - RETURN NEW; -- stop not found; skip silently - END IF; - - INSERT INTO public.communication_contacts - (brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata) - VALUES ( - v_brand_id, - NEW.customer_email, - NEW.customer_phone, - NEW.customer_name, - 'order', - NEW.customer_id, - true, - jsonb_build_object( - 'last_order_id', NEW.id, - 'last_order_at', now()::TEXT, - 'stop_id', NEW.stop_id::TEXT - ) - ) - ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET - full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name), - phone = COALESCE(EXCLUDED.phone, communication_contacts.phone), - customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id), - metadata = jsonb_build_object( - 'last_order_id', communication_contacts.metadata->>'last_order_id', - 'last_order_at', communication_contacts.metadata->>'last_order_at', - 'stop_id', NEW.stop_id::TEXT - ), - updated_at = now(); - -- NOTE: email_opt_in, unsubscribed_at, sms_opt_in are intentionally NOT - -- updated here — we preserve the existing opt-out status. - - RETURN NEW; -END; -$$; - --- 2. Trigger on orders INSERT --- Trigger functions receive NEW implicitly; no arguments after func name. -DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders; -CREATE TRIGGER trg_create_contact_from_order - AFTER INSERT ON public.orders - FOR EACH ROW - EXECUTE FUNCTION public.upsert_contact_from_order(); - --- 3. upsert_communication_contact (general upsert by email or phone) -DROP FUNCTION IF EXISTS public.upsert_communication_contact( - UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN, BOOLEAN, TEXT[], JSONB -); -CREATE OR REPLACE FUNCTION public.upsert_communication_contact( - p_id UUID, - p_brand_id UUID, - p_email TEXT, - p_phone TEXT, - p_first_name TEXT, - p_last_name TEXT, - p_full_name TEXT, - p_source TEXT, - p_external_id TEXT, - p_customer_id UUID, - p_email_opt_in BOOLEAN, - p_sms_opt_in BOOLEAN, - p_tags TEXT[], - p_metadata JSONB -) -RETURNS communication_contacts -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result communication_contacts; - v_id UUID; -BEGIN - v_id := coalesce(p_id, uuid_generate_v4()); - - IF p_email IS NOT NULL AND p_email != '' THEN - INSERT INTO public.communication_contacts - (id, brand_id, email, phone, first_name, last_name, full_name, source, - external_id, customer_id, email_opt_in, sms_opt_in, tags, metadata, - email_opt_in_at, sms_opt_in_at) - VALUES - (v_id, p_brand_id, p_email, p_phone, p_first_name, p_last_name, - p_full_name, p_source, p_external_id, p_customer_id, - coalesce(p_email_opt_in, true), coalesce(p_sms_opt_in, false), - coalesce(p_tags, '{}'), coalesce(p_metadata, '{}'), - CASE WHEN p_email_opt_in = true THEN now() ELSE NULL END, - CASE WHEN p_sms_opt_in = true THEN now() ELSE NULL END) - ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET - phone = COALESCE(EXCLUDED.phone, communication_contacts.phone), - first_name = COALESCE(EXCLUDED.first_name, communication_contacts.first_name), - last_name = COALESCE(EXCLUDED.last_name, communication_contacts.last_name), - full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name), - source = COALESCE(EXCLUDED.source, communication_contacts.source), - external_id = COALESCE(EXCLUDED.external_id, communication_contacts.external_id), - customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id), - email_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.email_opt_in - ELSE COALESCE(EXCLUDED.email_opt_in, communication_contacts.email_opt_in) - END, - sms_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.sms_opt_in - ELSE COALESCE(EXCLUDED.sms_opt_in, communication_contacts.sms_opt_in) - END, - tags = COALESCE(EXCLUDED.tags, communication_contacts.tags), - metadata = communication_contacts.metadata || COALESCE(EXCLUDED.metadata, '{}'), - updated_at = now() - RETURNING * INTO v_result; - - ELSIF p_phone IS NOT NULL AND p_phone != '' THEN - INSERT INTO public.communication_contacts - (id, brand_id, email, phone, first_name, last_name, full_name, source, - external_id, customer_id, email_opt_in, sms_opt_in, tags, metadata, - email_opt_in_at, sms_opt_in_at) - VALUES - (v_id, p_brand_id, NULL, p_phone, p_first_name, p_last_name, - p_full_name, p_source, p_external_id, p_customer_id, - coalesce(p_email_opt_in, true), coalesce(p_sms_opt_in, false), - coalesce(p_tags, '{}'), coalesce(p_metadata, '{}'), - CASE WHEN p_email_opt_in = true THEN now() ELSE NULL END, - CASE WHEN p_sms_opt_in = true THEN now() ELSE NULL END) - ON CONFLICT (brand_id, phone) WHERE phone IS NOT NULL DO UPDATE SET - email = COALESCE(EXCLUDED.email, communication_contacts.email), - first_name = COALESCE(EXCLUDED.first_name, communication_contacts.first_name), - last_name = COALESCE(EXCLUDED.last_name, communication_contacts.last_name), - full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name), - source = COALESCE(EXCLUDED.source, communication_contacts.source), - external_id = COALESCE(EXCLUDED.external_id, communication_contacts.external_id), - customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id), - email_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.email_opt_in - ELSE COALESCE(EXCLUDED.email_opt_in, communication_contacts.email_opt_in) - END, - sms_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.sms_opt_in - ELSE COALESCE(EXCLUDED.sms_opt_in, communication_contacts.sms_opt_in) - END, - tags = COALESCE(EXCLUDED.tags, communication_contacts.tags), - metadata = communication_contacts.metadata || COALESCE(EXCLUDED.metadata, '{}'), - updated_at = now() - RETURNING * INTO v_result; - ELSE - RETURN NULL; - END IF; - - RETURN v_result; -END; -$$; - --- 4. get_communication_contacts (paginated list with search) -DROP FUNCTION IF EXISTS public.get_communication_contacts(UUID, TEXT, TEXT, INTEGER, INTEGER); -CREATE OR REPLACE FUNCTION public.get_communication_contacts( - p_brand_id UUID, - p_search TEXT DEFAULT NULL, - p_source TEXT DEFAULT NULL, - p_limit INTEGER DEFAULT 100, - p_offset INTEGER DEFAULT 0 -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_contacts JSONB; - v_total INTEGER; -BEGIN - SELECT COUNT(*) INTO v_total - FROM public.communication_contacts c - WHERE (p_brand_id IS NULL OR c.brand_id = p_brand_id) - AND (p_source IS NULL OR c.source = p_source) - AND ( - p_search IS NULL - OR c.email ILIKE '%' || p_search || '%' - OR c.full_name ILIKE '%' || p_search || '%' - OR c.phone ILIKE '%' || p_search || '%' - ); - - SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_contacts - FROM ( - SELECT c.* - FROM public.communication_contacts c - WHERE (p_brand_id IS NULL OR c.brand_id = p_brand_id) - AND (p_source IS NULL OR c.source = p_source) - AND ( - p_search IS NULL - OR c.email ILIKE '%' || p_search || '%' - OR c.full_name ILIKE '%' || p_search || '%' - OR c.phone ILIKE '%' || p_search || '%' - ) - ORDER BY c.created_at DESC - LIMIT p_limit - OFFSET p_offset - ) c; - - RETURN jsonb_build_object( - 'contacts', v_contacts, - 'total', v_total, - 'limit', p_limit, - 'offset', p_offset - ); -END; -$$; - --- 5. import_communication_contacts_batch (bulk CSV upsert) --- Returns: { created: n, updated: n, skipped: n, errors: [...] } --- tags are stored as TEXT[]; CSV string "tag1;tag2" is split with string_to_array. -DROP FUNCTION IF EXISTS public.import_communication_contacts_batch(UUID, JSONB, BOOLEAN); -CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch( - p_brand_id UUID, - p_contacts JSONB, - p_allow_opt_in_override BOOLEAN DEFAULT false -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_entry JSONB; - v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB; - v_existing communication_contacts%ROWTYPE; - v_email TEXT; - v_phone TEXT; - v_tags TEXT[]; -BEGIN - FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts) - LOOP - v_email := nullif(v_entry->>'email', ''); - v_phone := nullif(v_entry->>'phone', ''); - - -- Parse tags: CSV sends "tag1;tag2" as a plain string; split into TEXT[] - IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN - v_tags := coalesce( - (SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''), - '{}' - ); - ELSE - v_tags := '{}'; - END IF; - - BEGIN - IF v_email IS NOT NULL AND v_email != '' THEN - SELECT * INTO v_existing - FROM public.communication_contacts - WHERE brand_id = p_brand_id AND email = v_email; - - IF FOUND THEN - -- Existing contact - IF v_existing.unsubscribed_at IS NOT NULL AND - coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND - p_allow_opt_in_override = false THEN - v_result := jsonb_set(v_result, '{skipped}', - to_jsonb((v_result->>'skipped')::INTEGER + 1)); - ELSE - UPDATE public.communication_contacts SET - phone = COALESCE(nullif(v_entry->>'phone', ''), phone), - first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name), - last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name), - full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name), - source = 'import', - external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id), - email_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.email_opt_in - ELSE coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) - END, - sms_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.sms_opt_in - ELSE coalesce( - (v_entry->>'sms_opt_in')::BOOLEAN, - communication_contacts.sms_opt_in, - false - ) - END, - email_opt_in_at = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN email_opt_in_at - WHEN coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) = true THEN now() - ELSE email_opt_in_at - END, - tags = CASE - WHEN v_tags != '{}' THEN v_tags - ELSE communication_contacts.tags - END, - metadata = communication_contacts.metadata || jsonb_build_object( - 'imported_at', now()::TEXT - ), - updated_at = now() - WHERE brand_id = p_brand_id AND email = v_email; - - v_result := jsonb_set(v_result, '{updated}', - to_jsonb((v_result->>'updated')::INTEGER + 1)); - END IF; - - ELSE - -- Insert new - INSERT INTO public.communication_contacts - (brand_id, email, phone, first_name, last_name, full_name, source, - external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at, - tags, metadata) - VALUES ( - p_brand_id, - v_email, - nullif(v_entry->>'phone', ''), - nullif(v_entry->>'first_name', ''), - nullif(v_entry->>'last_name', ''), - nullif(v_entry->>'full_name', ''), - 'import', - nullif(v_entry->>'external_id', ''), - coalesce((v_entry->>'email_opt_in')::BOOLEAN, true), - coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false), - CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END, - CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END, - v_tags, - jsonb_build_object('imported_at', now()::TEXT) - ); - v_result := jsonb_set(v_result, '{created}', - to_jsonb((v_result->>'created')::INTEGER + 1)); - END IF; - - ELSIF v_phone IS NOT NULL AND v_phone != '' THEN - -- Upsert by phone - SELECT * INTO v_existing - FROM public.communication_contacts - WHERE brand_id = p_brand_id AND phone = v_phone; - - IF FOUND THEN - IF v_existing.unsubscribed_at IS NOT NULL AND - coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND - p_allow_opt_in_override = false THEN - v_result := jsonb_set(v_result, '{skipped}', - to_jsonb((v_result->>'skipped')::INTEGER + 1)); - ELSE - UPDATE public.communication_contacts SET - email = COALESCE(nullif(v_entry->>'email', ''), email), - first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name), - last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name), - full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name), - source = 'import', - email_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.email_opt_in - ELSE coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) - END, - tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END, - metadata = communication_contacts.metadata || jsonb_build_object( - 'imported_at', now()::TEXT - ), - updated_at = now() - WHERE brand_id = p_brand_id AND phone = v_phone; - - v_result := jsonb_set(v_result, '{updated}', - to_jsonb((v_result->>'updated')::INTEGER + 1)); - END IF; - ELSE - INSERT INTO public.communication_contacts - (brand_id, email, phone, first_name, last_name, full_name, source, - email_opt_in, sms_opt_in, tags, metadata) - VALUES ( - p_brand_id, - nullif(v_entry->>'email', ''), - v_phone, - nullif(v_entry->>'first_name', ''), - nullif(v_entry->>'last_name', ''), - nullif(v_entry->>'full_name', ''), - 'import', - coalesce((v_entry->>'email_opt_in')::BOOLEAN, true), - coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false), - v_tags, - jsonb_build_object('imported_at', now()::TEXT) - ); - v_result := jsonb_set(v_result, '{created}', - to_jsonb((v_result->>'created')::INTEGER + 1)); - END IF; - - ELSE - -- No email or phone - v_result := jsonb_set( - v_result, '{errors}', - v_result->'errors' || jsonb_build_array( - jsonb_build_object('row', v_entry, 'error', 'No email or phone provided') - ) - ); - END IF; - - EXCEPTION WHEN OTHERS THEN - v_result := jsonb_set( - v_result, '{errors}', - v_result->'errors' || jsonb_build_array( - jsonb_build_object('row', v_entry, 'error', SQLERRM) - ) - ); - END; - END LOOP; - - RETURN v_result; -END; -$$; - --- 6. opt_out_contact (unsubscribe by email — public-facing, no auth required) -DROP FUNCTION IF EXISTS public.opt_out_contact(TEXT, UUID, TEXT); -CREATE OR REPLACE FUNCTION public.opt_out_contact( - p_email TEXT, - p_brand_id UUID, - p_method TEXT -) -RETURNS void -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - IF p_method = 'email' THEN - UPDATE public.communication_contacts SET - email_opt_in = false, - email_opt_in_at = now(), - unsubscribed_at = now(), - updated_at = now() - WHERE brand_id = p_brand_id AND email = p_email; - ELSIF p_method = 'sms' THEN - UPDATE public.communication_contacts SET - sms_opt_in = false, - sms_opt_in_at = now(), - unsubscribed_at = now(), - updated_at = now() - WHERE brand_id = p_brand_id AND (email = p_email OR phone = p_email); - END IF; -END; -$$; - --- 7. delete_communication_contact -DROP FUNCTION IF EXISTS public.delete_communication_contact(UUID); -CREATE OR REPLACE FUNCTION public.delete_communication_contact(p_id UUID) -RETURNS boolean -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - DELETE FROM public.communication_contacts WHERE id = p_id; - RETURN FOUND; -END; -$$; - --- ── Rewrite preview_campaign_audience to use communication_contacts ─────────── --- Primary source: communication_contacts (opted-in, not unsubscribed) --- Orders-based targeting still works but final recipient resolution is via contacts. - -DROP FUNCTION IF EXISTS public.preview_campaign_audience(UUID, JSONB); -CREATE OR REPLACE FUNCTION public.preview_campaign_audience( - p_brand_id UUID, - p_audience_rules JSONB -) -RETURNS jsonb -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_target TEXT; - v_stop_id UUID; - v_date_from TIMESTAMPTZ; - v_date_to TIMESTAMPTZ; - v_city TEXT; - v_order_hist TEXT; - v_days_back INTEGER; - v_product_id UUID; - v_count INTEGER; - v_contacts JSONB; -BEGIN - v_target := p_audience_rules->>'target'; - - -- Stop-based targeting: find orders for stop+date range, resolve to contacts - IF v_target = 'stop' THEN - v_stop_id := nullif(p_audience_rules->>'stop_id', '')::UUID; - v_date_from := nullif(p_audience_rules->>'date_from', '')::TIMESTAMPTZ; - v_date_to := nullif(p_audience_rules->>'date_to', '')::TIMESTAMPTZ; - - SELECT COUNT(DISTINCT c.id), - jsonb_agg(DISTINCT jsonb_build_object( - 'id', c.id, 'email', c.email, 'name', c.full_name, - 'phone', c.phone, 'source', c.source - ) ORDER BY c.full_name) - INTO v_count, v_contacts - FROM ( - SELECT DISTINCT o.customer_email - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE s.brand_id = p_brand_id - AND o.status NOT IN ('canceled') - AND (v_stop_id IS NULL OR o.stop_id = v_stop_id) - AND (v_date_from IS NULL OR o.created_at >= v_date_from) - AND (v_date_to IS NULL OR o.created_at <= v_date_to) - AND o.customer_email IS NOT NULL AND o.customer_email != '' - ) o_emails - JOIN communication_contacts c ON c.email = o_emails.customer_email - AND c.brand_id = p_brand_id - AND c.email_opt_in = true - AND c.unsubscribed_at IS NULL; - - -- ZIP / city targeting (placeholder — ZIP requires customer_zip column in orders) - ELSIF v_target = 'zip_code' THEN - SELECT COUNT(*), - jsonb_agg(jsonb_build_object( - 'id', c.id, 'email', c.email, 'name', c.full_name, - 'phone', c.phone, 'source', c.source - ) ORDER BY c.full_name) - INTO v_count, v_contacts - FROM communication_contacts c - WHERE c.brand_id = p_brand_id - AND c.email_opt_in = true - AND c.unsubscribed_at IS NULL; - - -- Customer history targeting - ELSIF v_target = 'customer_history' THEN - v_order_hist := p_audience_rules->>'order_history'; - v_days_back := (p_audience_rules->>'days_back')::INTEGER; - - IF v_order_hist = 'first_order' THEN - WITH first_order_emails AS ( - SELECT o.customer_email - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE s.brand_id = p_brand_id - AND o.status NOT IN ('canceled') - AND o.customer_email IS NOT NULL AND o.customer_email != '' - AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL) - GROUP BY o.customer_email - HAVING COUNT(*) = 1 - ) - SELECT COUNT(*), - jsonb_agg(jsonb_build_object( - 'id', c.id, 'email', c.email, 'name', c.full_name, - 'phone', c.phone, 'source', c.source - ) ORDER BY c.full_name) - INTO v_count, v_contacts - FROM first_order_emails fe - JOIN communication_contacts c ON c.email = fe.customer_email - AND c.brand_id = p_brand_id - AND c.email_opt_in = true - AND c.unsubscribed_at IS NULL; - - ELSIF v_order_hist = 'repeat' THEN - WITH repeat_emails AS ( - SELECT o.customer_email - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE s.brand_id = p_brand_id - AND o.status NOT IN ('canceled') - AND o.customer_email IS NOT NULL AND o.customer_email != '' - AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL) - GROUP BY o.customer_email - HAVING COUNT(*) > 1 - ) - SELECT COUNT(*), - jsonb_agg(jsonb_build_object( - 'id', c.id, 'email', c.email, 'name', c.full_name, - 'phone', c.phone, 'source', c.source - ) ORDER BY c.full_name) - INTO v_count, v_contacts - FROM repeat_emails re - JOIN communication_contacts c ON c.email = re.customer_email - AND c.brand_id = p_brand_id - AND c.email_opt_in = true - AND c.unsubscribed_at IS NULL; - - ELSE - WITH all_order_emails AS ( - SELECT DISTINCT o.customer_email - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE s.brand_id = p_brand_id - AND o.status NOT IN ('canceled') - AND o.customer_email IS NOT NULL AND o.customer_email != '' - AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL) - ) - SELECT COUNT(*), - jsonb_agg(jsonb_build_object( - 'id', c.id, 'email', c.email, 'name', c.full_name, - 'phone', c.phone, 'source', c.source - ) ORDER BY c.full_name) - INTO v_count, v_contacts - FROM all_order_emails aoe - JOIN communication_contacts c ON c.email = aoe.customer_email - AND c.brand_id = p_brand_id - AND c.email_opt_in = true - AND c.unsubscribed_at IS NULL; - END IF; - - -- Product-based targeting - ELSIF v_target = 'product' THEN - v_product_id := nullif(p_audience_rules->>'product_id', '')::UUID; - WITH product_order_emails AS ( - SELECT DISTINCT o.customer_email - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - JOIN stops s ON s.id = o.stop_id - WHERE s.brand_id = p_brand_id - AND o.status NOT IN ('canceled') - AND (v_product_id IS NULL OR oi.product_id = v_product_id) - AND o.customer_email IS NOT NULL AND o.customer_email != '' - ) - SELECT COUNT(*), - jsonb_agg(jsonb_build_object( - 'id', c.id, 'email', c.email, 'name', c.full_name, - 'phone', c.phone, 'source', c.source - ) ORDER BY c.full_name) - INTO v_count, v_contacts - FROM product_order_emails poe - JOIN communication_contacts c ON c.email = poe.customer_email - AND c.brand_id = p_brand_id - AND c.email_opt_in = true - AND c.unsubscribed_at IS NULL; - - -- Explicit customer ID list - ELSIF v_target = 'customer_ids' THEN - SELECT COUNT(*), - jsonb_agg(jsonb_build_object( - 'id', c.id, 'email', c.email, 'name', c.full_name, - 'phone', c.phone, 'source', c.source - ) ORDER BY c.full_name) - INTO v_count, v_contacts - FROM communication_contacts c - WHERE c.brand_id = p_brand_id - AND c.email_opt_in = true - AND c.unsubscribed_at IS NULL - AND c.customer_id IN ( - SELECT jsonb_array_elements_text(p_audience_rules->'customer_ids')::UUID - ); - - -- all_customers: all opted-in, not-unsubscribed contacts - ELSE - SELECT COUNT(*), - jsonb_agg(jsonb_build_object( - 'id', c.id, 'email', c.email, 'name', c.full_name, - 'phone', c.phone, 'source', c.source - ) ORDER BY c.full_name) - INTO v_count, v_contacts - FROM communication_contacts c - WHERE c.brand_id = p_brand_id - AND c.email_opt_in = true - AND c.unsubscribed_at IS NULL; - END IF; - - -- Cap sample at 20 - SELECT jsonb_agg(x ORDER BY x->>'name') INTO v_contacts - FROM ( - SELECT * FROM jsonb_array_elements(coalesce(v_contacts, '[]'::jsonb)) - LIMIT 20 - ) t(x); - - RETURN jsonb_build_object('count', v_count, 'sample_customers', v_contacts); -END; -$$; - --- ── Update send_campaign to use communication_contacts ────────────────────── - -DROP FUNCTION IF EXISTS public.send_campaign(UUID); -CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID) -RETURNS jsonb -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_campaign communication_campaigns; - v_settings communication_settings; - v_audience JSONB; - v_entry JSONB; - v_entries JSONB := '[]'::JSONB; - v_count INTEGER := 0; -BEGIN - SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Campaign not found'); - END IF; - - SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand'); - END IF; - - v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules); - - FOR v_entry IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb)) - LOOP - CONTINUE WHEN (v_entry->>'email') IS NULL OR (v_entry->>'email') = ''; - - v_entries := v_entries || jsonb_build_array(jsonb_build_object( - 'brand_id', v_campaign.brand_id, - 'campaign_id', p_campaign_id, - 'customer_id', nullif(v_entry->>'id', ''), - 'customer_email', v_entry->>'email', - 'delivery_method','email', - 'subject', v_campaign.subject, - 'body_preview', left(v_campaign.body_text, 500), - 'status', 'queued' - )); - v_count := v_count + 1; - END LOOP; - - PERFORM log_communication_messages(v_entries); - - UPDATE communication_campaigns - SET status = 'sent', sent_at = now(), updated_at = now() - WHERE id = p_campaign_id; - - RETURN jsonb_build_object('success', true, 'messages_logged', v_count); -END; -$$; - --- ── Update opt_out_customer to also set unsubscribed_at on contacts ─────────── - -DROP FUNCTION IF EXISTS public.opt_out_customer(UUID, UUID, TEXT); -CREATE OR REPLACE FUNCTION public.opt_out_customer( - p_customer_id UUID, - p_brand_id UUID, - p_method TEXT -) -RETURNS customer_communication_preferences -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE v_result customer_communication_preferences; -BEGIN - INSERT INTO public.customer_communication_preferences - (customer_id, brand_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at) - VALUES ( - p_customer_id, p_brand_id, - CASE WHEN p_method = 'email' THEN false ELSE true END, - CASE WHEN p_method = 'sms' THEN false ELSE true END, - CASE WHEN p_method = 'email' THEN now() ELSE NULL END, - CASE WHEN p_method = 'sms' THEN now() ELSE NULL END - ) - ON CONFLICT (customer_id, brand_id) DO UPDATE SET - email_opt_in = CASE WHEN p_method = 'email' THEN false ELSE customer_communication_preferences.email_opt_in END, - sms_opt_in = CASE WHEN p_method = 'sms' THEN false ELSE customer_communication_preferences.sms_opt_in END, - email_opt_in_at = CASE WHEN p_method = 'email' THEN now() ELSE email_opt_in_at END, - sms_opt_in_at = CASE WHEN p_method = 'sms' THEN now() ELSE sms_opt_in_at END, - updated_at = now() - RETURNING * INTO v_result; - - -- Also set unsubscribed_at on communication_contacts - IF p_method = 'email' THEN - UPDATE public.communication_contacts SET - email_opt_in = false, - email_opt_in_at = now(), - unsubscribed_at = now(), - updated_at = now() - WHERE customer_id = p_customer_id - AND brand_id = p_brand_id - AND unsubscribed_at IS NULL; - ELSIF p_method = 'sms' THEN - UPDATE public.communication_contacts SET - sms_opt_in = false, - sms_opt_in_at = now(), - unsubscribed_at = now(), - updated_at = now() - WHERE customer_id = p_customer_id - AND brand_id = p_brand_id - AND unsubscribed_at IS NULL; - END IF; - - RETURN v_result; -END; -$$; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/018_contact_import_metadata.sql b/supabase/migrations/.archived/018_contact_import_metadata.sql deleted file mode 100644 index 2eb279a..0000000 --- a/supabase/migrations/.archived/018_contact_import_metadata.sql +++ /dev/null @@ -1,230 +0,0 @@ --- ============================================================================= --- Communication Center V1.2 — Import Metadata Storage --- Fully idempotent: additive changes only --- Updates import_communication_contacts_batch to store ignored CSV columns --- in metadata.imported_raw so no context is lost from arbitrary extra columns. --- ============================================================================= - -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- ── Update email path — add imported_raw to metadata on UPDATE ─────────────── --- Lines ~400-402 in migration 017: UPDATE path for existing email contact - -CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch( - p_brand_id UUID, - p_contacts JSONB, - p_allow_opt_in_override BOOLEAN DEFAULT false -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_entry JSONB; - v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB; - v_existing communication_contacts%ROWTYPE; - v_email TEXT; - v_phone TEXT; - v_tags TEXT[]; - v_raw_meta JSONB; -BEGIN - FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts) - LOOP - v_email := nullif(v_entry->>'email', ''); - v_phone := nullif(v_entry->>'phone', ''); - - -- Parse tags: CSV sends "tag1;tag2" as a plain string; split into TEXT[] - IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN - v_tags := coalesce( - (SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''), - '{}' - ); - ELSE - v_tags := '{}'; - END IF; - - -- Build imported_raw from any _metadata key (ignored CSV columns) - v_raw_meta := nullif(v_entry->>'_metadata', '')::JSONB; - - BEGIN - IF v_email IS NOT NULL AND v_email != '' THEN - SELECT * INTO v_existing - FROM public.communication_contacts - WHERE brand_id = p_brand_id AND email = v_email; - - IF FOUND THEN - -- Existing contact - IF v_existing.unsubscribed_at IS NOT NULL AND - coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND - p_allow_opt_in_override = false THEN - v_result := jsonb_set(v_result, '{skipped}', - to_jsonb((v_result->>'skipped')::INTEGER + 1)); - ELSE - UPDATE public.communication_contacts SET - phone = COALESCE(nullif(v_entry->>'phone', ''), phone), - first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name), - last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name), - full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name), - source = 'import', - external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id), - email_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.email_opt_in - ELSE coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) - END, - sms_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.sms_opt_in - ELSE coalesce( - (v_entry->>'sms_opt_in')::BOOLEAN, - communication_contacts.sms_opt_in, - false - ) - END, - email_opt_in_at = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN email_opt_in_at - WHEN coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) = true THEN now() - ELSE email_opt_in_at - END, - tags = CASE - WHEN v_tags != '{}' THEN v_tags - ELSE communication_contacts.tags - END, - metadata = communication_contacts.metadata || - jsonb_build_object( - 'imported_at', now()::TEXT, - 'imported_raw', coalesce(v_raw_meta, '{}'::JSONB) - ), - updated_at = now() - WHERE brand_id = p_brand_id AND email = v_email; - - v_result := jsonb_set(v_result, '{updated}', - to_jsonb((v_result->>'updated')::INTEGER + 1)); - END IF; - - ELSE - -- Insert new - INSERT INTO public.communication_contacts - (brand_id, email, phone, first_name, last_name, full_name, source, - external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at, - tags, metadata) - VALUES ( - p_brand_id, - v_email, - nullif(v_entry->>'phone', ''), - nullif(v_entry->>'first_name', ''), - nullif(v_entry->>'last_name', ''), - nullif(v_entry->>'full_name', ''), - 'import', - nullif(v_entry->>'external_id', ''), - coalesce((v_entry->>'email_opt_in')::BOOLEAN, true), - coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false), - CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END, - CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END, - v_tags, - jsonb_build_object( - 'imported_at', now()::TEXT, - 'imported_raw', coalesce(v_raw_meta, '{}'::JSONB) - ) - ); - v_result := jsonb_set(v_result, '{created}', - to_jsonb((v_result->>'created')::INTEGER + 1)); - END IF; - - ELSIF v_phone IS NOT NULL AND v_phone != '' THEN - -- Upsert by phone - SELECT * INTO v_existing - FROM public.communication_contacts - WHERE brand_id = p_brand_id AND phone = v_phone; - - IF FOUND THEN - IF v_existing.unsubscribed_at IS NOT NULL AND - coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND - p_allow_opt_in_override = false THEN - v_result := jsonb_set(v_result, '{skipped}', - to_jsonb((v_result->>'skipped')::INTEGER + 1)); - ELSE - UPDATE public.communication_contacts SET - email = COALESCE(nullif(v_entry->>'email', ''), email), - first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name), - last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name), - full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name), - source = 'import', - email_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.email_opt_in - ELSE coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) - END, - tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END, - metadata = communication_contacts.metadata || - jsonb_build_object( - 'imported_at', now()::TEXT, - 'imported_raw', coalesce(v_raw_meta, '{}'::JSONB) - ), - updated_at = now() - WHERE brand_id = p_brand_id AND phone = v_phone; - - v_result := jsonb_set(v_result, '{updated}', - to_jsonb((v_result->>'updated')::INTEGER + 1)); - END IF; - ELSE - INSERT INTO public.communication_contacts - (brand_id, email, phone, first_name, last_name, full_name, source, - email_opt_in, sms_opt_in, tags, metadata) - VALUES ( - p_brand_id, - nullif(v_entry->>'email', ''), - v_phone, - nullif(v_entry->>'first_name', ''), - nullif(v_entry->>'last_name', ''), - nullif(v_entry->>'full_name', ''), - 'import', - coalesce((v_entry->>'email_opt_in')::BOOLEAN, true), - coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false), - v_tags, - jsonb_build_object( - 'imported_at', now()::TEXT, - 'imported_raw', coalesce(v_raw_meta, '{}'::JSONB) - ) - ); - v_result := jsonb_set(v_result, '{created}', - to_jsonb((v_result->>'created')::INTEGER + 1)); - END IF; - - ELSE - -- No email or phone - v_result := jsonb_set( - v_result, '{errors}', - v_result->'errors' || jsonb_build_array( - jsonb_build_object('row', v_entry, 'error', 'No email or phone provided') - ) - ); - END IF; - - EXCEPTION WHEN OTHERS THEN - v_result := jsonb_set( - v_result, '{errors}', - v_result->'errors' || jsonb_build_array( - jsonb_build_object('row', v_entry, 'error', SQLERRM) - ) - ); - END; - END LOOP; - - RETURN v_result; -END; -$$; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/019_customers_table.sql b/supabase/migrations/.archived/019_customers_table.sql deleted file mode 100644 index 681e1fc..0000000 --- a/supabase/migrations/.archived/019_customers_table.sql +++ /dev/null @@ -1,75 +0,0 @@ --- ============================================================================= --- V1.2 Stage 1 — Canonical Customers Table --- Fully idempotent: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS --- Purely additive: no changes to orders, communication_contacts, or checkout --- --- STAGE 2 NOTE: When upserting customers in Stage 2, normalize BEFORE upsert: --- - email: lowercase + trim --- - phone: strip formatting chars [\s\-().[\]], preserve original if uncertain --- This ensures consistent matching across orders, imports, and contacts. --- ============================================================================= - -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- ── Table ──────────────────────────────────────────────────────────────────── - -CREATE TABLE IF NOT EXISTS public.customers ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - primary_email TEXT, - primary_phone TEXT, - first_name TEXT, - last_name TEXT, - source TEXT NOT NULL DEFAULT 'system', - metadata JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT customers_email_or_phone CHECK ( - primary_email IS NOT NULL OR primary_phone IS NOT NULL - ) -); - --- ── Indexes ─────────────────────────────────────────────────────────────────── - -CREATE INDEX IF NOT EXISTS idx_customers_brand - ON public.customers(brand_id); -CREATE INDEX IF NOT EXISTS idx_customers_email - ON public.customers(primary_email) WHERE primary_email IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_customers_phone - ON public.customers(primary_phone) WHERE primary_phone IS NOT NULL; - --- Partial unique indexes: enforce one customer per brand per email/phone --- PostgreSQL requires CREATE UNIQUE INDEX rather than CONSTRAINT for partial unique -CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_email_unique - ON public.customers(brand_id, primary_email) - WHERE primary_email IS NOT NULL; - -CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_phone_unique - ON public.customers(brand_id, primary_phone) - WHERE primary_phone IS NOT NULL; - --- ── RLS ─────────────────────────────────────────────────────────────────────── - -ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Brand admin can read customers" - ON public.customers; -CREATE POLICY "Brand admin can read customers" - ON public.customers FOR SELECT TO authenticated - USING (brand_id IN ( - SELECT brand_id FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'brand_admin' - )); - -DROP POLICY IF EXISTS "Platform admin can read customers" - ON public.customers; -CREATE POLICY "Platform admin can read customers" - ON public.customers FOR SELECT TO authenticated - USING (EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - )); - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/020_order_customer_linking.sql b/supabase/migrations/.archived/020_order_customer_linking.sql deleted file mode 100644 index 16e0b03..0000000 --- a/supabase/migrations/.archived/020_order_customer_linking.sql +++ /dev/null @@ -1,226 +0,0 @@ --- ============================================================================= --- V1.2 Stage 2 — Order/Contact/Customer Linking --- Fully idempotent: CREATE OR REPLACE FUNCTION, no destructive operations --- Purely additive behavior: no changes to orders schema, no FKs, no backfill --- --- DEPENDENCY: Requires 019_customers_table.sql to be applied first. --- Raises an exception if the customers table does not exist. --- --- TRANSITIONAL ARCHITECTURE NOTE: --- orders.customer_id and customers.id are separate ID namespaces today. --- communication_contacts.customer_id links to customers.id for new order-created --- contacts (via this migration), but orders.customer_id is NOT yet aligned. --- --- A future stage (Stage 3+) should consider: --- - backfilling customers from existing orders/communication_contacts --- - aligning orders.customer_id to canonical customers.id --- - adding proper FK constraints only after data is cleaned --- - avoiding permanent dual customer ID namespaces --- --- STAGE 2 NORMALIZATION: --- email: trim(lower(...)) before match/upsert --- phone: regexp_replace(... '[\s\-().[\]]' '' 'g') before match/upsert --- This is consistent with frontend normalizeEmail/normalizePhone. --- ============================================================================= - --- ── Dependency guard ───────────────────────────────────────────────────────── -DO $$ -BEGIN - IF to_regclass('public.customers') IS NULL THEN - RAISE EXCEPTION 'Migration 019_customers_table.sql must be applied before 020_order_customer_linking.sql'; - END IF; -END; -$$; - -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- ── Safe teardown order: trigger → functions ──────────────────────────────── --- PostgreSQL requires dropping a trigger before dropping the function it uses. --- Order: DROP TRIGGER → DROP FUNCTION (helper) → DROP FUNCTION (trigger) - -DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders; -DROP FUNCTION IF EXISTS public.upsert_customer_from_order(UUID, TEXT, TEXT, TEXT, TEXT); -DROP FUNCTION IF EXISTS public.upsert_contact_from_order(); - --- ───────────────────────────────────────────────────────────────────────────── --- upsert_customer_from_order --- --- Resolves or creates a canonical customers record for a given order. --- Returns the customers.id for linking, or NULL if no contact method exists. --- --- Upsert order: email first → phone fallback (email is more stable identity) --- Brand separation: brand_id is the scoping key throughout. --- ───────────────────────────────────────────────────────────────────────────── - -CREATE OR REPLACE FUNCTION public.upsert_customer_from_order( - p_brand_id UUID, - p_customer_email TEXT, - p_customer_phone TEXT, - p_customer_name TEXT, - p_source TEXT DEFAULT 'order' -) -RETURNS UUID -- customers.id -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_norm_email TEXT := trim(lower(p_customer_email)); - v_norm_phone TEXT := regexp_replace(p_customer_phone, '[\s\-().[\]]', '', 'g'); - v_cust_id UUID; -BEGIN - -- No email AND no phone: nothing to upsert - IF (v_norm_email IS NULL OR v_norm_email = '') AND - (v_norm_phone IS NULL OR v_norm_phone = '') THEN - RETURN NULL; - END IF; - - -- Try email match first (within brand) - IF v_norm_email IS NOT NULL AND v_norm_email != '' THEN - SELECT id INTO v_cust_id - FROM public.customers - WHERE brand_id = p_brand_id - AND primary_email = v_norm_email; - - IF FOUND THEN - UPDATE public.customers SET - primary_phone = COALESCE(NULLIF(v_norm_phone, ''), primary_phone), - first_name = COALESCE( - NULLIF(split_part(p_customer_name, ' ', 1), ''), - customers.first_name - ), - last_name = COALESCE( - NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1 - for length(p_customer_name)), ''), - customers.last_name - ), - source = CASE - WHEN customers.source = 'system' THEN p_source ELSE customers.source - END, - updated_at = now() - WHERE id = v_cust_id; - RETURN v_cust_id; - END IF; - END IF; - - -- Phone fallback (only if email didn't find a match) - IF v_cust_id IS NULL AND v_norm_phone IS NOT NULL AND v_norm_phone != '' THEN - SELECT id INTO v_cust_id - FROM public.customers - WHERE brand_id = p_brand_id - AND primary_phone = v_norm_phone; - - IF FOUND THEN - UPDATE public.customers SET - primary_email = COALESCE(NULLIF(v_norm_email, ''), primary_email), - first_name = COALESCE( - NULLIF(split_part(p_customer_name, ' ', 1), ''), - customers.first_name - ), - last_name = COALESCE( - NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1 - for length(p_customer_name)), ''), - customers.last_name - ), - source = CASE - WHEN customers.source = 'system' THEN p_source ELSE customers.source - END, - updated_at = now() - WHERE id = v_cust_id; - RETURN v_cust_id; - END IF; - END IF; - - -- Insert new customer - INSERT INTO public.customers - (brand_id, primary_email, primary_phone, first_name, last_name, source) - VALUES ( - p_brand_id, - NULLIF(v_norm_email, ''), - NULLIF(v_norm_phone, ''), - NULLIF(split_part(p_customer_name, ' ', 1), ''), - NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1 - for length(p_customer_name)), ''), - p_source - ) - RETURNING id INTO v_cust_id; - - RETURN v_cust_id; -END; -$$; - --- ───────────────────────────────────────────────────────────────────────────── --- upsert_contact_from_order trigger --- --- NOW DOES TWO THINGS: --- 1. Call upsert_customer_from_order() to get/create customers.id --- 2. Use that customers.id as communication_contacts.customer_id (linking) --- --- OPT-OUT PRESERVATION: email_opt_in, sms_opt_in, unsubscribed_at are NOT --- updated on conflict — this behavior is unchanged from V1.1. --- ───────────────────────────────────────────────────────────────────────────── - -CREATE OR REPLACE FUNCTION public.upsert_contact_from_order() -RETURNS TRIGGER -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_brand_id UUID; - v_customer_id UUID; -BEGIN - -- Resolve brand_id from the stop - SELECT brand_id INTO v_brand_id - FROM public.stops - WHERE id = NEW.stop_id; - IF NOT FOUND THEN - RETURN NEW; - END IF; - - -- Step 1: upsert canonical customer (NEW.customer_id not referenced — - -- orders table does not have that column; use only email/phone/name) - v_customer_id := upsert_customer_from_order( - v_brand_id, - NEW.customer_email, - NEW.customer_phone, - NEW.customer_name, - 'order' - ); - - -- Step 2: upsert communication contact, linked to customers.id - INSERT INTO public.communication_contacts - (brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata) - VALUES ( - v_brand_id, - NEW.customer_email, - NEW.customer_phone, - NEW.customer_name, - 'order', - v_customer_id, - true, - jsonb_build_object( - 'last_order_id', NEW.id, - 'last_order_at', now()::TEXT, - 'stop_id', NEW.stop_id::TEXT - ) - ) - ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET - full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name), - phone = COALESCE(EXCLUDED.phone, communication_contacts.phone), - customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id), - metadata = jsonb_build_object( - 'last_order_id', communication_contacts.metadata->>'last_order_id', - 'last_order_at', communication_contacts.metadata->>'last_order_at', - 'stop_id', NEW.stop_id::TEXT - ), - updated_at = now(); - -- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out) - - RETURN NEW; -END; -$$; - --- ── Recreate trigger ───────────────────────────────────────────────────────── -CREATE TRIGGER trg_create_contact_from_order - AFTER INSERT ON public.orders - FOR EACH ROW - EXECUTE FUNCTION public.upsert_contact_from_order(); - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/021_shipping_only_brand.sql b/supabase/migrations/.archived/021_shipping_only_brand.sql deleted file mode 100644 index 1162702..0000000 --- a/supabase/migrations/.archived/021_shipping_only_brand.sql +++ /dev/null @@ -1,319 +0,0 @@ --- ============================================================================= --- V1.2 Stage 3 — Shipping-Only Brand Resolution --- Fully idempotent: CREATE OR REPLACE FUNCTION, ALTER TABLE ADD COLUMN IF NOT EXISTS --- --- DEPENDENCY: Requires 020_order_customer_linking.sql to be applied first. --- Raises an exception if the trigger function does not exist. --- --- CHANGES: --- 1. Add brand_id column to orders (nullable, no FK — aligned to products.brand_id) --- 2. Modify create_order_with_items to accept NULL stop_id for shipping-only --- 3. Update upsert_contact_from_order trigger to resolve brand from NEW.brand_id --- first, falling back to stop lookup — supports both pickup and shipping orders --- ============================================================================= - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = 'public' - AND p.proname = 'upsert_contact_from_order' - ) THEN - RAISE EXCEPTION 'Migration 020_order_customer_linking.sql must be applied before 021_shipping_only_brand.sql'; - END IF; -END; -$$; - --- ── 1. Add brand_id to orders ──────────────────────────────────────────────── --- Allows the order/contact trigger to resolve brand without needing a stop_id. --- For shipping-only orders, brand is derived from the first product. - -ALTER TABLE orders ADD COLUMN IF NOT EXISTS brand_id UUID; - --- ── 2. Modify create_order_with_items for shipping-only ──────────────────── --- stop_id is now nullable. When NULL (shipping-only), brand is derived from --- the first product's brand and no stop validation occurs. - -CREATE OR REPLACE FUNCTION create_order_with_items( - p_idempotency_key UUID, - p_customer_name TEXT, - p_customer_email TEXT, - p_customer_phone TEXT, - p_stop_id UUID, -- nullable for shipping-only - p_items JSONB -- [{id: uuid, quantity: int, fulfillment: text}] -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order_id UUID; - v_item JSONB; - v_product_id UUID; - v_quantity INT; - v_fulfillment TEXT; - v_product_price NUMERIC; - v_computed_total NUMERIC := 0; - v_stop_active BOOLEAN; - v_stop_brand_id UUID; - v_stop_city TEXT; - v_stop_state TEXT; - v_stop_date TEXT; - v_stop_time TEXT; - v_stop_location TEXT; - v_product_active BOOLEAN; - v_product_brand_id UUID; - v_product_name TEXT; - v_is_pickup BOOLEAN; - v_order JSONB; - v_order_items JSONB := '[]'::JSONB; - v_brand_id UUID; -BEGIN - -- ── Idempotency guard ───────────────────────────────────────────────────── - SELECT jsonb_build_object( - 'id', o.id, - 'customer_name', o.customer_name, - 'customer_email', o.customer_email, - 'customer_phone', o.customer_phone, - 'subtotal', o.subtotal, - 'status', o.status, - 'stop_id', o.stop_id, - 'brand_id', o.brand_id, - 'stop_city', s.city, - 'stop_state', s.state, - 'stop_date', s.date, - 'stop_time', s.time, - 'stop_location', s.location, - 'items', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'product_id', oi.product_id, - 'product_name', p.name, - 'quantity', oi.quantity, - 'price', oi.price, - 'fulfillment', oi.fulfillment - )) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id - ), '[]'::JSONB) - ) - INTO v_order - FROM orders o - LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.idempotency_key = p_idempotency_key - LIMIT 1; - - IF v_order IS NOT NULL THEN - RETURN v_order; - END IF; - - -- ── Resolve brand_id ────────────────────────────────────────────────────── - -- For shipping-only orders (p_stop_id IS NULL), derive from the first product. - -- For pickup orders, derive from the stop. - IF p_stop_id IS NOT NULL THEN - SELECT active, brand_id, city, state, date, time, location - INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location - FROM stops - WHERE id = p_stop_id; - - IF v_stop_brand_id IS NULL THEN - RAISE EXCEPTION 'Stop not found: %', p_stop_id; - END IF; - - IF NOT v_stop_active THEN - RAISE EXCEPTION 'Stop is not active: %', p_stop_id; - END IF; - - v_brand_id := v_stop_brand_id; - ELSE - -- Shipping-only: resolve brand from first product in the order - v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID; - - SELECT brand_id INTO v_brand_id - FROM products - WHERE id = v_product_id; - - IF v_brand_id IS NULL THEN - RAISE EXCEPTION 'Product not found: %', v_product_id; - END IF; - END IF; - - -- ── Validate items and compute subtotal ───────────────────────────────── - FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) - LOOP - v_product_id := (v_item->>'id')::UUID; - v_quantity := (v_item->>'quantity')::INT; - v_fulfillment := v_item->>'fulfillment'; - v_is_pickup := (v_fulfillment = 'pickup'); - - SELECT active, brand_id, price, name - INTO v_product_active, v_product_brand_id, v_product_price, v_product_name - FROM products - WHERE id = v_product_id; - - IF v_product_brand_id IS NULL THEN - RAISE EXCEPTION 'Product not found: %', v_product_id; - END IF; - - IF v_product_brand_id != v_brand_id THEN - RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id; - END IF; - - IF NOT v_product_active THEN - RAISE EXCEPTION 'Product is not active: %', v_product_id; - END IF; - - IF v_is_pickup THEN - -- Pickup items require a valid stop assignment - IF p_stop_id IS NULL THEN - RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id; - END IF; - - PERFORM 1 - FROM product_stops - WHERE product_id = v_product_id AND stop_id = p_stop_id - LIMIT 1; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id; - END IF; - END IF; - - v_computed_total := v_computed_total + (v_product_price * v_quantity); - END LOOP; - - -- ── Create order (with brand_id) ───────────────────────────────────────── - INSERT INTO orders ( - idempotency_key, customer_name, customer_email, customer_phone, - stop_id, brand_id, subtotal, status - ) VALUES ( - p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone, - p_stop_id, v_brand_id, v_computed_total, 'pending' - ) - RETURNING id INTO v_order_id; - - -- ── Insert order items and build return value ───────────────────────────── - FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) - LOOP - v_product_id := (v_item->>'id')::UUID; - v_quantity := (v_item->>'quantity')::INT; - v_fulfillment := v_item->>'fulfillment'; - - SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id; - - INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price) - VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price); - - v_order_items := v_order_items || jsonb_build_object( - 'product_id', v_product_id, - 'product_name', v_product_name, - 'quantity', v_quantity, - 'price', v_product_price, - 'fulfillment', v_fulfillment - ); - END LOOP; - - -- ── Build and return full order object ─────────────────────────────────── - RETURN jsonb_build_object( - 'id', v_order_id, - 'customer_name', p_customer_name, - 'customer_email', p_customer_email, - 'customer_phone', p_customer_phone, - 'subtotal', v_computed_total, - 'status', 'pending', - 'stop_id', p_stop_id, - 'brand_id', v_brand_id, - 'stop_city', v_stop_city, - 'stop_state', v_stop_state, - 'stop_date', v_stop_date, - 'stop_time', v_stop_time, - 'stop_location', v_stop_location, - 'items', v_order_items - ); -END; -$$; - --- ── 3. Update trigger to resolve brand from NEW.brand_id first ─────────────── --- NEW.brand_id is the primary source (always set by create_order_with_items). --- Stop lookup is the fallback (for pre-existing orders that lack brand_id). - -DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders; -DROP FUNCTION IF EXISTS public.upsert_contact_from_order(); - -CREATE OR REPLACE FUNCTION public.upsert_contact_from_order() -RETURNS TRIGGER -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_brand_id UUID; - v_customer_id UUID; -BEGIN - -- Primary: resolve brand_id from the order itself (NEW.brand_id, always set in Stage 3+) - IF NEW.brand_id IS NOT NULL THEN - v_brand_id := NEW.brand_id; - ELSE - -- Fallback: resolve via stop (for pre-Stage 3 orders without brand_id) - SELECT brand_id INTO v_brand_id - FROM public.stops - WHERE id = NEW.stop_id; - IF NOT FOUND THEN - RETURN NEW; -- cannot resolve brand: skip customer/contact linking - END IF; - END IF; - - -- No email AND no phone: nothing to upsert - IF (NEW.customer_email IS NULL OR NEW.customer_email = '') AND - (NEW.customer_phone IS NULL OR NEW.customer_phone = '') THEN - RETURN NEW; - END IF; - - -- Step 1: upsert canonical customer - v_customer_id := upsert_customer_from_order( - v_brand_id, - NEW.customer_email, - NEW.customer_phone, - NEW.customer_name, - 'order' - ); - - -- Step 2: upsert communication contact, linked to customers.id - INSERT INTO public.communication_contacts - (brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata) - VALUES ( - v_brand_id, - NEW.customer_email, - NEW.customer_phone, - NEW.customer_name, - 'order', - v_customer_id, - true, - jsonb_build_object( - 'last_order_id', NEW.id, - 'last_order_at', now()::TEXT, - 'stop_id', NEW.stop_id::TEXT - ) - ) - ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET - full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name), - phone = COALESCE(EXCLUDED.phone, communication_contacts.phone), - customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id), - metadata = jsonb_build_object( - 'last_order_id', communication_contacts.metadata->>'last_order_id', - 'last_order_at', communication_contacts.metadata->>'last_order_at', - 'stop_id', NEW.stop_id::TEXT - ), - updated_at = now(); - -- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out) - - RETURN NEW; -END; -$$; - -CREATE TRIGGER trg_create_contact_from_order - AFTER INSERT ON public.orders - FOR EACH ROW - EXECUTE FUNCTION public.upsert_contact_from_order(); - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/022_operational_events.sql b/supabase/migrations/.archived/022_operational_events.sql deleted file mode 100644 index c0a6acd..0000000 --- a/supabase/migrations/.archived/022_operational_events.sql +++ /dev/null @@ -1,646 +0,0 @@ --- ───────────────────────────────────────────────────────────────────────────── --- Migration 022: Operational Events --- ───────────────────────────────────────────────────────────────────────────── --- Append-only event layer for recording important platform actions. --- Scope: table + indexes + RLS + helpers + 4 initial emitters. --- What NOT included: automation, queues, webhooks, retries, cron jobs. - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. operational_events table --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS public.operational_events ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - event_type TEXT NOT NULL, - entity_type TEXT, - entity_id UUID, - actor_type TEXT, - actor_id UUID, - source TEXT NOT NULL DEFAULT 'system', - payload JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - CONSTRAINT operational_events_event_type CHECK ( - event_type ~ '^[a-z][a-z0-9_]*$' - ) -); - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. Indexes --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE INDEX IF NOT EXISTS idx_oe_brand_id ON operational_events(brand_id); -CREATE INDEX IF NOT EXISTS idx_oe_event_type ON operational_events(event_type); -CREATE INDEX IF NOT EXISTS idx_oe_entity ON operational_events(entity_type, entity_id); -CREATE INDEX IF NOT EXISTS idx_oe_created_at ON operational_events(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_oe_brand_type ON operational_events(brand_id, event_type, created_at DESC); - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. RLS policies --- ═══════════════════════════════════════════════════════════════════════════ - -ALTER TABLE operational_events ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Brand admin can read operational_events" ON operational_events; -CREATE POLICY "Brand admin can read operational_events" - ON operational_events FOR SELECT TO authenticated - USING ( - brand_id IN ( - SELECT brand_id FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'brand_admin' - ) - ); - -DROP POLICY IF EXISTS "Platform admin can read operational_events" ON operational_events; -CREATE POLICY "Platform admin can read operational_events" - ON operational_events FOR SELECT TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - ) - ); - --- Writes go through SECURITY DEFINER helpers only. --- No INSERT granted to authenticated roles. - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. record_operational_event helper — bypasses RLS, all emitters call this --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.record_operational_event( - p_brand_id UUID, - p_event_type TEXT, - p_entity_type TEXT, - p_entity_id UUID, - p_actor_type TEXT, - p_actor_id UUID, - p_source TEXT, - p_payload JSONB -) -RETURNS void -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO operational_events ( - brand_id, event_type, entity_type, entity_id, - actor_type, actor_id, source, payload - ) VALUES ( - p_brand_id, p_event_type, p_entity_type, p_entity_id, - p_actor_type, p_actor_id, p_source, p_payload - ); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 5. record_pickup_completed_event helper — called from TypeScript action --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.record_pickup_completed_event( - p_order_id UUID, - p_brand_id UUID, - p_actor_id UUID -) -RETURNS void -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order_data JSONB; -BEGIN - SELECT jsonb_build_object( - 'subtotal', subtotal, - 'customer_name', customer_name - ) INTO v_order_data - FROM orders - WHERE id = p_order_id; - - PERFORM record_operational_event( - p_brand_id, - 'pickup_completed', - 'order', - p_order_id, - 'admin', - p_actor_id, - 'system', - coalesce(v_order_data, '{}'::JSONB) - ); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 6. create_order_with_items — with order_placed emitter --- (explicit replace; same signature as migration 021) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION create_order_with_items( - p_idempotency_key UUID, - p_customer_name TEXT, - p_customer_email TEXT, - p_customer_phone TEXT, - p_stop_id UUID, - p_items JSONB -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order_id UUID; - v_item JSONB; - v_product_id UUID; - v_quantity INT; - v_fulfillment TEXT; - v_product_price NUMERIC; - v_computed_total NUMERIC := 0; - v_stop_active BOOLEAN; - v_stop_brand_id UUID; - v_stop_city TEXT; - v_stop_state TEXT; - v_stop_date TEXT; - v_stop_time TEXT; - v_stop_location TEXT; - v_product_active BOOLEAN; - v_product_brand_id UUID; - v_product_name TEXT; - v_is_pickup BOOLEAN; - v_has_pickup BOOLEAN := false; - v_order JSONB; - v_order_items JSONB := '[]'::JSONB; - v_brand_id UUID; -BEGIN - -- ── Idempotency guard ───────────────────────────────────────────────────── - SELECT jsonb_build_object( - 'id', o.id, - 'customer_name', o.customer_name, - 'customer_email', o.customer_email, - 'customer_phone', o.customer_phone, - 'subtotal', o.subtotal, - 'status', o.status, - 'stop_id', o.stop_id, - 'brand_id', o.brand_id, - 'stop_city', s.city, - 'stop_state', s.state, - 'stop_date', s.date, - 'stop_time', s.time, - 'stop_location', s.location, - 'items', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'product_id', oi.product_id, - 'product_name', p.name, - 'quantity', oi.quantity, - 'price', oi.price, - 'fulfillment', oi.fulfillment - )) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id - ), '[]'::JSONB) - ) - INTO v_order - FROM orders o - LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.idempotency_key = p_idempotency_key - LIMIT 1; - - IF v_order IS NOT NULL THEN - RETURN v_order; - END IF; - - -- ── Resolve brand_id ────────────────────────────────────────────────────── - IF p_stop_id IS NOT NULL THEN - SELECT active, brand_id, city, state, date, time, location - INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location - FROM stops - WHERE id = p_stop_id; - - IF v_stop_brand_id IS NULL THEN - RAISE EXCEPTION 'Stop not found: %', p_stop_id; - END IF; - - IF NOT v_stop_active THEN - RAISE EXCEPTION 'Stop is not active: %', p_stop_id; - END IF; - - v_brand_id := v_stop_brand_id; - ELSE - v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID; - - SELECT brand_id INTO v_brand_id - FROM products - WHERE id = v_product_id; - - IF v_brand_id IS NULL THEN - RAISE EXCEPTION 'Product not found: %', v_product_id; - END IF; - END IF; - - -- ── Validate items and compute subtotal ───────────────────────────────── - FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) - LOOP - v_product_id := (v_item->>'id')::UUID; - v_quantity := (v_item->>'quantity')::INT; - v_fulfillment := v_item->>'fulfillment'; - v_is_pickup := (v_fulfillment = 'pickup'); - - IF v_is_pickup THEN - v_has_pickup := true; - END IF; - - SELECT active, brand_id, price, name - INTO v_product_active, v_product_brand_id, v_product_price, v_product_name - FROM products - WHERE id = v_product_id; - - IF v_product_brand_id IS NULL THEN - RAISE EXCEPTION 'Product not found: %', v_product_id; - END IF; - - IF v_product_brand_id != v_brand_id THEN - RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id; - END IF; - - IF NOT v_product_active THEN - RAISE EXCEPTION 'Product is not active: %', v_product_id; - END IF; - - IF v_is_pickup THEN - IF p_stop_id IS NULL THEN - RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id; - END IF; - - PERFORM 1 - FROM product_stops - WHERE product_id = v_product_id AND stop_id = p_stop_id - LIMIT 1; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id; - END IF; - END IF; - - v_computed_total := v_computed_total + (v_product_price * v_quantity); - END LOOP; - - -- ── Create order (with brand_id) ───────────────────────────────────────── - INSERT INTO orders ( - idempotency_key, customer_name, customer_email, customer_phone, - stop_id, brand_id, subtotal, status - ) VALUES ( - p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone, - p_stop_id, v_brand_id, v_computed_total, 'pending' - ) - RETURNING id INTO v_order_id; - - -- ── Insert order items and build return value ───────────────────────────── - FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) - LOOP - v_product_id := (v_item->>'id')::UUID; - v_quantity := (v_item->>'quantity')::INT; - v_fulfillment := v_item->>'fulfillment'; - - SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id; - - INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price) - VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price); - - v_order_items := v_order_items || jsonb_build_object( - 'product_id', v_product_id, - 'product_name', v_product_name, - 'quantity', v_quantity, - 'price', v_product_price, - 'fulfillment', v_fulfillment - ); - END LOOP; - - -- ── Emit order_placed event ─────────────────────────────────────────────── - PERFORM record_operational_event( - v_brand_id, - 'order_placed', - 'order', - v_order_id, - 'customer', - NULL, - 'system', - jsonb_build_object( - 'subtotal', v_computed_total, - 'item_count', jsonb_array_length(p_items), - 'has_pickup', v_has_pickup - ) - ); - - -- ── Build and return full order object ─────────────────────────────────── - RETURN jsonb_build_object( - 'id', v_order_id, - 'customer_name', p_customer_name, - 'customer_email', p_customer_email, - 'customer_phone', p_customer_phone, - 'subtotal', v_computed_total, - 'status', 'pending', - 'stop_id', p_stop_id, - 'brand_id', v_brand_id, - 'stop_city', v_stop_city, - 'stop_state', v_stop_state, - 'stop_date', v_stop_date, - 'stop_time', v_stop_time, - 'stop_location', v_stop_location, - 'items', v_order_items - ); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 7. import_communication_contacts_batch — with contact_imported emitter --- (explicit replace; same signature as migrations 017/018) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch( - p_brand_id UUID, - p_contacts JSONB, - p_allow_opt_in_override BOOLEAN DEFAULT false -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_entry JSONB; - v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB; - v_existing communication_contacts%ROWTYPE; - v_email TEXT; - v_phone TEXT; - v_tags TEXT[]; -BEGIN - FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts) - LOOP - v_email := nullif(v_entry->>'email', ''); - v_phone := nullif(v_entry->>'phone', ''); - - IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN - v_tags := coalesce( - (SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''), - '{}' - ); - ELSE - v_tags := '{}'; - END IF; - - BEGIN - IF v_email IS NOT NULL AND v_email != '' THEN - SELECT * INTO v_existing - FROM public.communication_contacts - WHERE brand_id = p_brand_id AND email = v_email; - - IF FOUND THEN - IF v_existing.unsubscribed_at IS NOT NULL AND - coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND - p_allow_opt_in_override = false THEN - v_result := jsonb_set(v_result, '{skipped}', - to_jsonb((v_result->>'skipped')::INTEGER + 1)); - ELSE - UPDATE public.communication_contacts SET - phone = COALESCE(nullif(v_entry->>'phone', ''), phone), - first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name), - last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name), - full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name), - source = 'import', - external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id), - email_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.email_opt_in - ELSE coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) - END, - sms_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.sms_opt_in - ELSE coalesce( - (v_entry->>'sms_opt_in')::BOOLEAN, - communication_contacts.sms_opt_in, - false - ) - END, - email_opt_in_at = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN email_opt_in_at - WHEN coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) = true THEN now() - ELSE email_opt_in_at - END, - tags = CASE - WHEN v_tags != '{}' THEN v_tags - ELSE communication_contacts.tags - END, - metadata = communication_contacts.metadata || jsonb_build_object( - 'imported_at', now()::TEXT - ), - updated_at = now() - WHERE brand_id = p_brand_id AND email = v_email; - - v_result := jsonb_set(v_result, '{updated}', - to_jsonb((v_result->>'updated')::INTEGER + 1)); - END IF; - - ELSE - INSERT INTO public.communication_contacts - (brand_id, email, phone, first_name, last_name, full_name, source, - external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at, - tags, metadata) - VALUES ( - p_brand_id, - v_email, - nullif(v_entry->>'phone', ''), - nullif(v_entry->>'first_name', ''), - nullif(v_entry->>'last_name', ''), - nullif(v_entry->>'full_name', ''), - 'import', - nullif(v_entry->>'external_id', ''), - coalesce((v_entry->>'email_opt_in')::BOOLEAN, true), - coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false), - CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END, - CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END, - v_tags, - jsonb_build_object('imported_at', now()::TEXT) - ); - v_result := jsonb_set(v_result, '{created}', - to_jsonb((v_result->>'created')::INTEGER + 1)); - END IF; - - ELSIF v_phone IS NOT NULL AND v_phone != '' THEN - SELECT * INTO v_existing - FROM public.communication_contacts - WHERE brand_id = p_brand_id AND phone = v_phone; - - IF FOUND THEN - IF v_existing.unsubscribed_at IS NOT NULL AND - coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND - p_allow_opt_in_override = false THEN - v_result := jsonb_set(v_result, '{skipped}', - to_jsonb((v_result->>'skipped')::INTEGER + 1)); - ELSE - UPDATE public.communication_contacts SET - email = COALESCE(nullif(v_entry->>'email', ''), email), - first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name), - last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name), - full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name), - source = 'import', - email_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.email_opt_in - ELSE coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) - END, - tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END, - metadata = communication_contacts.metadata || jsonb_build_object( - 'imported_at', now()::TEXT - ), - updated_at = now() - WHERE brand_id = p_brand_id AND phone = v_phone; - - v_result := jsonb_set(v_result, '{updated}', - to_jsonb((v_result->>'updated')::INTEGER + 1)); - END IF; - ELSE - INSERT INTO public.communication_contacts - (brand_id, email, phone, first_name, last_name, full_name, source, - email_opt_in, sms_opt_in, tags, metadata) - VALUES ( - p_brand_id, - nullif(v_entry->>'email', ''), - v_phone, - nullif(v_entry->>'first_name', ''), - nullif(v_entry->>'last_name', ''), - nullif(v_entry->>'full_name', ''), - 'import', - coalesce((v_entry->>'email_opt_in')::BOOLEAN, true), - coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false), - v_tags, - jsonb_build_object('imported_at', now()::TEXT) - ); - v_result := jsonb_set(v_result, '{created}', - to_jsonb((v_result->>'created')::INTEGER + 1)); - END IF; - - ELSE - v_result := jsonb_set( - v_result, '{errors}', - v_result->'errors' || jsonb_build_array( - jsonb_build_object('row', v_entry, 'error', 'No email or phone provided') - ) - ); - END IF; - - EXCEPTION WHEN OTHERS THEN - v_result := jsonb_set( - v_result, '{errors}', - v_result->'errors' || jsonb_build_array( - jsonb_build_object('row', v_entry, 'error', SQLERRM) - ) - ); - END; - END LOOP; - - -- ── Emit contact_imported event ────────────────────────────────────────── - PERFORM record_operational_event( - p_brand_id, - 'contact_imported', - NULL, - NULL, - 'admin', - NULL, - 'system', - jsonb_build_object( - 'created', (v_result->>'created')::INTEGER, - 'updated', (v_result->>'updated')::INTEGER, - 'skipped', (v_result->>'skipped')::INTEGER, - 'total', - (v_result->>'created')::INTEGER - + (v_result->>'updated')::INTEGER - + (v_result->>'skipped')::INTEGER - ) - ); - - RETURN v_result; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 8. send_campaign — with campaign_sent emitter --- (explicit replace; same body as migration 017) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID) -RETURNS jsonb -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_campaign communication_campaigns; - v_settings communication_settings; - v_audience JSONB; - v_entry JSONB; - v_entries JSONB := '[]'::JSONB; - v_count INTEGER := 0; -BEGIN - SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Campaign not found'); - END IF; - - SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand'); - END IF; - - v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules); - - FOR v_entry IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb)) - LOOP - CONTINUE WHEN (v_entry->>'email') IS NULL OR (v_entry->>'email') = ''; - - v_entries := v_entries || jsonb_build_array(jsonb_build_object( - 'brand_id', v_campaign.brand_id, - 'campaign_id', p_campaign_id, - 'customer_id', nullif(v_entry->>'id', ''), - 'customer_email', v_entry->>'email', - 'delivery_method','email', - 'subject', v_campaign.subject, - 'body_preview', left(v_campaign.body_text, 500), - 'status', 'queued' - )); - v_count := v_count + 1; - END LOOP; - - PERFORM log_communication_messages(v_entries); - - UPDATE communication_campaigns - SET status = 'sent', sent_at = now(), updated_at = now() - WHERE id = p_campaign_id; - - -- ── Emit campaign_sent event ──────────────────────────────────────────── - PERFORM record_operational_event( - v_campaign.brand_id, - 'campaign_sent', - 'campaign', - p_campaign_id, - 'system', - NULL, - 'system', - jsonb_build_object( - 'messages_logged', v_count, - 'subject', v_campaign.subject - ) - ); - - RETURN jsonb_build_object('success', true, 'messages_logged', v_count); -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/023_cart_availability_check.sql b/supabase/migrations/.archived/023_cart_availability_check.sql deleted file mode 100644 index 767d447..0000000 --- a/supabase/migrations/.archived/023_cart_availability_check.sql +++ /dev/null @@ -1,56 +0,0 @@ --- Migration 023: Fix cart availability check --- Replaces unreliable client-side product_stops query with a --- SECURITY DEFINER RPC that bypasses RLS and returns structured availability. --- --- The cart page's availability check was: --- 1. Unreliable — anon/frontend query may be blocked by RLS or return empty --- 2. Conflating "no rows" with "product is unavailable" --- 3. Blocking all stops even when the query itself failed --- --- This adds: check_stop_product_availability(p_stop_id, p_product_ids) --- Returns: { product_id, is_available }[] for each requested product. --- The cart page uses this to show truly incompatible items separately --- from query errors. - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. check_stop_product_availability RPC --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.check_stop_product_availability( - p_stop_id UUID, - p_product_ids UUID[] -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB := '[]'::JSONB; - v_pid UUID; -BEGIN - FOR v_pid IN SELECT unnest(p_product_ids) - LOOP - v_result := v_result || jsonb_build_array(jsonb_build_object( - 'product_id', v_pid, - 'is_available', EXISTS( - SELECT 1 FROM product_stops - WHERE stop_id = p_stop_id AND product_id = v_pid - ) - )); - END LOOP; - - RETURN v_result; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. Update cart/page.tsx to use the RPC + show query errors distinctly --- (done in the application code, not the migration) --- ═══════════════════════════════════════════════════════════════════════════ --- --- Changes to src/app/cart/page.tsx: --- - handleStopSelect: POST to check_stop_product_availability RPC instead of --- direct product_stops query. Handle errors distinctly from unavailability. --- - Add availabilityError state — if RPC fails, show "Unable to verify --- availability" but allow checkout to proceed (server will catch true errors). --- - Add per-product availabilityError flag to distinguish query failure --- from confirmed unavailability. \ No newline at end of file diff --git a/supabase/migrations/.archived/024_stop_product_assignment_rpcs.sql b/supabase/migrations/.archived/024_stop_product_assignment_rpcs.sql deleted file mode 100644 index 2a05496..0000000 --- a/supabase/migrations/.archived/024_stop_product_assignment_rpcs.sql +++ /dev/null @@ -1,146 +0,0 @@ --- Migration 024: Stop-product assignment via SECURITY DEFINER RPCs --- Replaces direct INSERT/DELETE on product_stops (blocked by RLS) --- with admin-authorized RPCs that validate brand alignment. - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. assign_product_to_stop --- Idempotent: if row already exists, returns the existing row. --- Validates: stop exists, product exists, brands match. --- Authorizes: platform_admin (all brands) or brand_admin (own brand only). --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.assign_product_to_stop( - p_stop_id UUID, - p_product_id UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_existing UUID; - v_result JSONB; -BEGIN - -- Verify stop exists and get its brand - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Verify product exists and get its brand - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Brand alignment check - IF v_stop_brand_id != v_product_brand_id THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed' - ); - END IF; - - -- Authorization: must be platform_admin or brand_admin for this brand - IF NOT EXISTS ( - SELECT 1 FROM admin_users - WHERE user_id = auth.uid() - AND (role = 'platform_admin' OR (role = 'brand_admin' AND brand_id = v_stop_brand_id)) - ) THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to assign products to this stop'); - END IF; - - -- Idempotent insert: check if row already exists - SELECT id INTO v_existing - FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - IF v_existing IS NOT NULL THEN - RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true); - END IF; - - -- Insert new row - INSERT INTO product_stops (stop_id, product_id) - VALUES (p_stop_id, p_product_id) - RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id) - INTO v_result; - - RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. unassign_product_from_stop --- Deletes the product_stop row. Safe — no side effects if row doesn't exist. --- Authorizes: platform_admin (all brands) or brand_admin (own brand only). --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.unassign_product_from_stop( - p_stop_id UUID, - p_product_id UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; -BEGIN - -- Verify stop exists and get its brand - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Authorization: must be platform_admin or brand_admin for this brand - IF NOT EXISTS ( - SELECT 1 FROM admin_users - WHERE user_id = auth.uid() - AND (role = 'platform_admin' OR (role = 'brand_admin' AND brand_id = v_stop_brand_id)) - ) THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to unassign products from this stop'); - END IF; - - DELETE FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - RETURN jsonb_build_object('success', true, 'deleted', true); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. get_stop_products --- Returns all products assigned to a stop, with product details. --- Used by admin UI to refresh the list after assign/unassign. --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN jsonb_build_object('products', ( - SELECT COALESCE(jsonb_agg( - jsonb_build_object( - 'id', ps.id, - 'product_id', ps.product_id, - 'name', p.name, - 'type', p.type, - 'price', p.price - ) - ), '[]'::JSONB) - FROM product_stops ps - JOIN products p ON p.id = ps.product_id - WHERE ps.stop_id = p_stop_id - )); -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/025_fix_assignment_rpc_auth.sql b/supabase/migrations/.archived/025_fix_assignment_rpc_auth.sql deleted file mode 100644 index 4ded899..0000000 --- a/supabase/migrations/.archived/025_fix_assignment_rpc_auth.sql +++ /dev/null @@ -1,177 +0,0 @@ --- Migration 025: Fix admin assignment RPC authorization --- --- Problem: auth.uid() is NULL when RPC is called via REST (anon key). --- SECURITY DEFINER runs as postgres, but auth.uid() reflects the session user. --- A direct REST call has no authenticated session → auth.uid() = NULL. --- --- Fix: accept p_caller_uid as an explicit parameter from the admin UI. --- The UI already knows the current user from getAdminUser(). --- The RPC validates authorization by looking up admin_users with p_caller_uid. - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. assign_product_to_stop (corrected auth) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.assign_product_to_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid UUID -- explicitly passed by the admin UI -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_existing UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_result JSONB; -BEGIN - -- Verify stop exists and get its brand - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Verify product exists and get its brand - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Brand alignment check - IF v_stop_brand_id != v_product_brand_id THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed' - ); - END IF; - - -- Look up the admin user with the explicitly-passed caller UID - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as an admin user'); - END IF; - - -- Authorization: platform_admin can manage any stop; brand_admin only their brand - IF v_admin_role = 'platform_admin' THEN - -- platform_admin: allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- brand_admin for this brand: allowed - ELSE - RETURN jsonb_build_object( - 'success', false, - 'error', 'Not authorized to assign products to this stop — requires platform_admin or brand_admin for this brand' - ); - END IF; - - -- Idempotent: if already assigned, return existing row - SELECT id INTO v_existing - FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - IF v_existing IS NOT NULL THEN - RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true); - END IF; - - -- Insert new row - INSERT INTO product_stops (stop_id, product_id) - VALUES (p_stop_id, p_product_id) - RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id) - INTO v_result; - - RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. unassign_product_from_stop (corrected auth) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.unassign_product_from_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; -BEGIN - -- Verify stop exists and get its brand - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Look up admin user - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as an admin user'); - END IF; - - -- Authorization - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object( - 'success', false, - 'error', 'Not authorized to unassign products from this stop' - ); - END IF; - - DELETE FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - RETURN jsonb_build_object('success', true, 'deleted', true); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. get_stop_products — no auth needed for product list (reads data only) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN jsonb_build_object('products', ( - SELECT COALESCE(jsonb_agg( - jsonb_build_object( - 'id', ps.id, - 'product_id', ps.product_id, - 'name', p.name, - 'type', p.type, - 'price', p.price - ) - ), '[]'::JSONB) - FROM product_stops ps - JOIN products p ON p.id = ps.product_id - WHERE ps.stop_id = p_stop_id - )); -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/026_debug_assignment_rpc.sql b/supabase/migrations/.archived/026_debug_assignment_rpc.sql deleted file mode 100644 index d5686ac..0000000 --- a/supabase/migrations/.archived/026_debug_assignment_rpc.sql +++ /dev/null @@ -1,101 +0,0 @@ --- Migration 026: Debug stop-product assignment --- Temporary diagnostic RPC to reveal exactly why assignment authorization fails. --- Remove this after the bug is fixed. - --- ═══════════════════════════════════════════════════════════════════════════ --- debug_stop_product_assignment --- Returns a detailed diagnostics object so we can see which check failed. --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_admin_found BOOLEAN := false; - v_stop_found BOOLEAN := false; - v_product_found BOOLEAN := false; - v_brand_match BOOLEAN; - v_authorized BOOLEAN := false; - v_reason TEXT := 'not checked'; -BEGIN - -- Check stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF FOUND THEN - v_stop_found := true; - ELSE - v_reason := 'stop not found'; - END IF; - - -- Check product - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF FOUND THEN - v_product_found := true; - ELSE - v_reason := 'product not found'; - END IF; - - -- Check admin_users - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid; - - IF FOUND THEN - v_admin_found := true; - ELSE - v_reason := 'admin user not found in admin_users table'; - END IF; - - -- Brand match - IF v_stop_found AND v_product_found THEN - v_brand_match := (v_stop_brand_id = v_product_brand_id); - IF NOT v_brand_match THEN - v_reason := 'brand mismatch between stop and product'; - END IF; - END IF; - - -- Authorization decision - IF v_admin_found AND v_stop_found AND v_product_found AND v_brand_match THEN - IF v_admin_role = 'platform_admin' THEN - v_authorized := true; - v_reason := 'authorized as platform_admin'; - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - v_authorized := true; - v_reason := 'authorized as brand_admin for this brand'; - ELSE - v_authorized := false; - v_reason := 'admin role "' || v_admin_role || '" with brand_id "' || - COALESCE(v_admin_brand_id::TEXT, 'NULL') || - '" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"'; - END IF; - END IF; - - RETURN jsonb_build_object( - 'caller_uid', p_caller_uid, - 'admin_found', v_admin_found, - 'admin_role', v_admin_role, - 'admin_brand_id', v_admin_brand_id, - 'stop_found', v_stop_found, - 'stop_brand_id', v_stop_brand_id, - 'product_found', v_product_found, - 'product_brand_id', v_product_brand_id, - 'brand_match', v_brand_match, - 'authorized', v_authorized, - 'reason', v_reason - ); -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/027_fix_rpc_signature_conflict.sql b/supabase/migrations/.archived/027_fix_rpc_signature_conflict.sql deleted file mode 100644 index d285b6b..0000000 --- a/supabase/migrations/.archived/027_fix_rpc_signature_conflict.sql +++ /dev/null @@ -1,185 +0,0 @@ --- Migration 027: Fix assignment RPC signature conflict --- --- Root cause: Migration 024 created assign_product_to_stop(p_stop_id, p_product_id) --- with 2 params. Migration 025 tried to CREATE OR REPLACE it with 3 params, --- but CREATE OR REPLACE cannot change parameter count — the replacement failed --- and the stale 2-param version remains in the schema. --- --- Frontend calls with 3 params (p_caller_uid), but PostgreSQL matches the --- 2-param overload and returns "function does not exist" (400) because the --- 3-param call has no match. --- --- Fix: --- 1. DROP the old 2-param overloads explicitly --- 2. CREATE the 3-param versions cleanly --- 3. Notify PostgREST schema cache to reload --- ═══════════════════════════════════════════════════════════════════════════ - --- Drop stale 2-param versions (migrations 024) -DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID); -DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID); - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. assign_product_to_stop (3-param, SECURITY DEFINER) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.assign_product_to_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_existing UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_result JSONB; -BEGIN - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Verify product - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Cross-brand check - IF v_stop_brand_id != v_product_brand_id THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Product brand does not match stop brand' - ); - END IF; - - -- Look up caller in admin_users - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization: platform_admin OR brand_admin for this brand - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object( - 'success', false, - 'error', 'Not authorized — requires platform_admin or brand_admin for this brand' - ); - END IF; - - -- Idempotent insert - SELECT id INTO v_existing - FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - IF v_existing IS NOT NULL THEN - RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true); - END IF; - - INSERT INTO product_stops (stop_id, product_id) - VALUES (p_stop_id, p_product_id) - RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id) - INTO v_result; - - RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. unassign_product_from_stop (3-param, SECURITY DEFINER) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.unassign_product_from_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; -BEGIN - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Look up caller - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object('success', false, 'error', 'Not authorized'); - END IF; - - DELETE FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - RETURN jsonb_build_object('success', true, 'deleted', true); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. get_stop_products (no auth, product list) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN jsonb_build_object('products', ( - SELECT COALESCE(jsonb_agg( - jsonb_build_object('id', ps.id, 'product_id', ps.product_id, - 'name', p.name, 'type', p.type, 'price', p.price) - ), '[]'::JSONB) - FROM product_stops ps - JOIN products p ON p.id = ps.product_id - WHERE ps.stop_id = p_stop_id - )); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. Refresh PostgREST schema cache so it picks up the new signatures --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/028_fix_caller_uid_type.sql b/supabase/migrations/.archived/028_fix_caller_uid_type.sql deleted file mode 100644 index 61bdccb..0000000 --- a/supabase/migrations/.archived/028_fix_caller_uid_type.sql +++ /dev/null @@ -1,243 +0,0 @@ --- Migration 028: Fix dev-user UUID mismatch and clean up RPC signatures --- --- Root cause: p_caller_uid was defined as UUID, but the dev session --- (getAdminUser) returns user_id = 'dev-user-00000000-...' which is not --- a valid UUID. PostgreSQL rejects it with "invalid input syntax for type uuid". --- --- Fix: change p_caller_uid to TEXT — comparison with admin_users.user_id --- (UUID column) works fine since PostgreSQL casts TEXT to UUID implicitly. --- --- Also cleans up: drops stale 2-param overloads so PostgREST has no ambiguity. - --- ═══════════════════════════════════════════════════════════════════════════ --- Drop stale 2-param overloads from migrations 024/025 --- ═══════════════════════════════════════════════════════════════════════════ - -DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID); -DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID); - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. assign_product_to_stop (p_caller_uid is TEXT to accept dev-user format) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.assign_product_to_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -- TEXT to accept both UUIDs and "dev-user-..." strings -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_existing UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_result JSONB; -BEGIN - -- Verify stop exists - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Verify product exists - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Cross-brand guard - IF v_stop_brand_id != v_product_brand_id THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed' - ); - END IF; - - -- Look up admin by user_id (admin_users.user_id is UUID; TEXT cast works) - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid::UUID - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization: platform_admin OR brand_admin for this brand - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object( - 'success', false, - 'error', 'Not authorized — requires platform_admin or brand_admin for this brand' - ); - END IF; - - -- Idempotent insert - SELECT id INTO v_existing - FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - IF v_existing IS NOT NULL THEN - RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true); - END IF; - - INSERT INTO product_stops (stop_id, product_id) - VALUES (p_stop_id, p_product_id) - RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id) - INTO v_result; - - RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. unassign_product_from_stop (p_caller_uid is TEXT) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.unassign_product_from_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; -BEGIN - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Look up admin - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid::UUID - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object('success', false, 'error', 'Not authorized'); - END IF; - - DELETE FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - RETURN jsonb_build_object('success', true, 'deleted', true); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. debug_stop_product_assignment (p_caller_uid is TEXT) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_admin_found BOOLEAN := false; - v_stop_found BOOLEAN := false; - v_product_found BOOLEAN := false; - v_brand_match BOOLEAN; - v_authorized BOOLEAN := false; - v_reason TEXT := 'not checked'; -BEGIN - -- Check stop - SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id; - IF FOUND THEN v_stop_found := true; END IF; - - -- Check product - SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id; - IF FOUND THEN v_product_found := true; END IF; - - -- Check admin_users - BEGIN - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid::UUID; - IF FOUND THEN v_admin_found := true; END IF; - EXCEPTION WHEN OTHERS THEN - v_reason := 'admin lookup failed: ' || SQLERRM; - END; - - -- Brand match - IF v_stop_found AND v_product_found THEN - v_brand_match := (v_stop_brand_id = v_product_brand_id); - END IF; - - -- Authorization decision - IF v_admin_found AND v_stop_found AND v_product_found THEN - IF v_brand_match THEN - IF v_admin_role = 'platform_admin' THEN - v_authorized := true; v_reason := 'authorized as platform_admin'; - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - v_authorized := true; v_reason := 'authorized as brand_admin for this brand'; - ELSE - v_authorized := false; - v_reason := 'role "' || v_admin_role || '" with brand_id "' || - COALESCE(v_admin_brand_id::TEXT, 'NULL') || - '" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"'; - END IF; - ELSE - v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || - ', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL'); - END IF; - END IF; - - RETURN jsonb_build_object( - 'caller_uid', p_caller_uid, - 'admin_found', v_admin_found, - 'admin_role', v_admin_role, - 'admin_brand_id', v_admin_brand_id, - 'stop_found', v_stop_found, - 'stop_brand_id', v_stop_brand_id, - 'product_found', v_product_found, - 'product_brand_id', v_product_brand_id, - 'brand_match', v_brand_match, - 'authorized', v_authorized, - 'reason', v_reason - ); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. Refresh PostgREST schema cache --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/029_drop_stale_overloads.sql b/supabase/migrations/.archived/029_drop_stale_overloads.sql deleted file mode 100644 index 673d7a8..0000000 --- a/supabase/migrations/.archived/029_drop_stale_overloads.sql +++ /dev/null @@ -1,261 +0,0 @@ --- Migration 029: Remove all stale overloads, keep only TEXT caller_uid versions --- --- Root cause: Migration 028 changed p_caller_uid to TEXT in the CREATE OR REPLACE --- body, but the 3-param UUID overload (from migrations 024/025/027) was never --- dropped. PostgreSQL now has TWO valid candidates: --- assign_product_to_stop(uuid, uuid, text) -- migration 028 --- assign_product_to_stop(uuid, uuid, uuid) -- migration 027 --- PostgREST cannot resolve which to call → "Could not choose the best candidate". --- --- Fix: DROP all old signatures explicitly, then CREATE ONLY the TEXT versions. --- ═══════════════════════════════════════════════════════════════════════════ - --- ═══════════════════════════════════════════════════════════════════════════ --- Drop ALL stale overloads for each function --- ═══════════════════════════════════════════════════════════════════════════ - --- assign_product_to_stop -DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID); -DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID, UUID); - --- unassign_product_from_stop -DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID); -DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID, UUID); - --- debug_stop_product_assignment -DROP FUNCTION IF EXISTS public.debug_stop_product_assignment(UUID, UUID, UUID); - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. assign_product_to_stop (TEXT caller_uid) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.assign_product_to_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_existing UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_result JSONB; -BEGIN - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Verify product - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Cross-brand guard - IF v_stop_brand_id != v_product_brand_id THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed' - ); - END IF; - - -- Look up admin by user_id (admin_users.user_id is UUID; TEXT cast works) - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid::UUID - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization: platform_admin OR brand_admin for this brand - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object( - 'success', false, - 'error', 'Not authorized — requires platform_admin or brand_admin for this brand' - ); - END IF; - - -- Idempotent insert - SELECT id INTO v_existing - FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - IF v_existing IS NOT NULL THEN - RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true); - END IF; - - INSERT INTO product_stops (stop_id, product_id) - VALUES (p_stop_id, p_product_id) - RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id) - INTO v_result; - - RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. unassign_product_from_stop (TEXT caller_uid) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.unassign_product_from_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; -BEGIN - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Look up admin - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid::UUID - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object('success', false, 'error', 'Not authorized'); - END IF; - - DELETE FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - RETURN jsonb_build_object('success', true, 'deleted', true); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. debug_stop_product_assignment (TEXT caller_uid) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_admin_found BOOLEAN := false; - v_stop_found BOOLEAN := false; - v_product_found BOOLEAN := false; - v_brand_match BOOLEAN; - v_authorized BOOLEAN := false; - v_reason TEXT := 'not checked'; -BEGIN - -- Check stop - SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id; - IF FOUND THEN v_stop_found := true; END IF; - - -- Check product - SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id; - IF FOUND THEN v_product_found := true; END IF; - - -- Check admin_users - BEGIN - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid::UUID; - IF FOUND THEN v_admin_found := true; END IF; - EXCEPTION WHEN OTHERS THEN - v_reason := 'admin lookup failed: ' || SQLERRM; - END; - - -- Brand match - IF v_stop_found AND v_product_found THEN - v_brand_match := (v_stop_brand_id = v_product_brand_id); - END IF; - - -- Authorization decision - IF v_admin_found AND v_stop_found AND v_product_found THEN - IF v_brand_match THEN - IF v_admin_role = 'platform_admin' THEN - v_authorized := true; v_reason := 'authorized as platform_admin'; - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - v_authorized := true; v_reason := 'authorized as brand_admin for this brand'; - ELSE - v_authorized := false; - v_reason := 'role "' || v_admin_role || '" with brand_id "' || - COALESCE(v_admin_brand_id::TEXT, 'NULL') || - '" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"'; - END IF; - ELSE - v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || - ', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL'); - END IF; - END IF; - - RETURN jsonb_build_object( - 'caller_uid', p_caller_uid, - 'admin_found', v_admin_found, - 'admin_role', v_admin_role, - 'admin_brand_id', v_admin_brand_id, - 'stop_found', v_stop_found, - 'stop_brand_id', v_stop_brand_id, - 'product_found', v_product_found, - 'product_brand_id', v_product_brand_id, - 'brand_match', v_brand_match, - 'authorized', v_authorized, - 'reason', v_reason - ); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- Verify: confirm only TEXT signatures remain --- ═══════════════════════════════════════════════════════════════════════════ - -SELECT proname, oidvectortypes(proargtypes) AS arg_types -FROM pg_proc -WHERE proname IN ('assign_product_to_stop', 'unassign_product_from_stop', 'debug_stop_product_assignment') - AND pronamespace = 'public'::regnamespace; - --- ═══════════════════════════════════════════════════════════════════════════ --- Refresh PostgREST schema cache --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/030_normalize_dev_user_caller_uid.sql b/supabase/migrations/.archived/030_normalize_dev_user_caller_uid.sql deleted file mode 100644 index d833c49..0000000 --- a/supabase/migrations/.archived/030_normalize_dev_user_caller_uid.sql +++ /dev/null @@ -1,268 +0,0 @@ --- Migration 030: Normalize dev-user prefix before UUID cast in admin lookup --- --- Root cause: p_caller_uid = 'dev-user-00000000-...' is not a valid UUID string. --- p_caller_uid::UUID throws "invalid input syntax for type uuid" before the --- admin_users lookup can run. The lookup fails even for valid admin UUIDs because --- the cast throws first. --- --- Fix: normalize p_caller_uid before casting: --- - If it starts with 'dev-user-', strip that prefix then cast to UUID --- - Otherwise cast directly --- This lets the existing NOT FOUND handling catch the dev-user case gracefully. --- --- Also adds exception handling around the cast so invalid strings don't crash --- the function — the IF NOT FOUND path handles it. --- --- Applies to: assign_product_to_stop, unassign_product_from_stop, debug_stop_product_assignment --- ═══════════════════════════════════════════════════════════════════════════ - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. assign_product_to_stop --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.assign_product_to_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_existing UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_result JSONB; - v_lookup_uid UUID; -BEGIN - -- Normalize: strip 'dev-user-' prefix before UUID cast - IF p_caller_uid LIKE 'dev-user-%' THEN - v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID; - ELSE - v_lookup_uid := p_caller_uid::UUID; - END IF; - - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Verify product - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Cross-brand guard - IF v_stop_brand_id != v_product_brand_id THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed' - ); - END IF; - - -- Look up admin by normalized user_id - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = v_lookup_uid - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization: platform_admin OR brand_admin for this brand - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object( - 'success', false, - 'error', 'Not authorized — requires platform_admin or brand_admin for this brand' - ); - END IF; - - -- Idempotent insert - SELECT id INTO v_existing - FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - IF v_existing IS NOT NULL THEN - RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true); - END IF; - - INSERT INTO product_stops (stop_id, product_id) - VALUES (p_stop_id, p_product_id) - RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id) - INTO v_result; - - RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. unassign_product_from_stop --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.unassign_product_from_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_lookup_uid UUID; -BEGIN - -- Normalize: strip 'dev-user-' prefix before UUID cast - IF p_caller_uid LIKE 'dev-user-%' THEN - v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID; - ELSE - v_lookup_uid := p_caller_uid::UUID; - END IF; - - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Look up admin - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = v_lookup_uid - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object('success', false, 'error', 'Not authorized'); - END IF; - - DELETE FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - RETURN jsonb_build_object('success', true, 'deleted', true); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. debug_stop_product_assignment --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_admin_found BOOLEAN := false; - v_stop_found BOOLEAN := false; - v_product_found BOOLEAN := false; - v_brand_match BOOLEAN; - v_authorized BOOLEAN := false; - v_reason TEXT := 'not checked'; - v_lookup_uid UUID; -BEGIN - -- Normalize: strip 'dev-user-' prefix before UUID cast - IF p_caller_uid LIKE 'dev-user-%' THEN - v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID; - ELSE - v_lookup_uid := p_caller_uid::UUID; - END IF; - - -- Check stop - SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id; - IF FOUND THEN v_stop_found := true; END IF; - - -- Check product - SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id; - IF FOUND THEN v_product_found := true; END IF; - - -- Check admin_users - BEGIN - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = v_lookup_uid; - IF FOUND THEN v_admin_found := true; END IF; - EXCEPTION WHEN OTHERS THEN - v_reason := 'admin lookup failed: ' || SQLERRM; - END; - - -- Brand match - IF v_stop_found AND v_product_found THEN - v_brand_match := (v_stop_brand_id = v_product_brand_id); - END IF; - - -- Authorization decision - IF v_admin_found AND v_stop_found AND v_product_found THEN - IF v_brand_match THEN - IF v_admin_role = 'platform_admin' THEN - v_authorized := true; v_reason := 'authorized as platform_admin'; - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - v_authorized := true; v_reason := 'authorized as brand_admin for this brand'; - ELSE - v_authorized := false; - v_reason := 'role "' || v_admin_role || '" with brand_id "' || - COALESCE(v_admin_brand_id::TEXT, 'NULL') || - '" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"'; - END IF; - ELSE - v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || - ', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL'); - END IF; - END IF; - - RETURN jsonb_build_object( - 'caller_uid', p_caller_uid, - 'lookup_uid', v_lookup_uid::TEXT, - 'admin_found', v_admin_found, - 'admin_role', v_admin_role, - 'admin_brand_id', v_admin_brand_id, - 'stop_found', v_stop_found, - 'stop_brand_id', v_stop_brand_id, - 'product_found', v_product_found, - 'product_brand_id', v_product_brand_id, - 'brand_match', v_brand_match, - 'authorized', v_authorized, - 'reason', v_reason - ); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- Refresh PostgREST schema cache --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/031_reports_v1_rpcs.sql b/supabase/migrations/.archived/031_reports_v1_rpcs.sql deleted file mode 100644 index 827fae9..0000000 --- a/supabase/migrations/.archived/031_reports_v1_rpcs.sql +++ /dev/null @@ -1,337 +0,0 @@ --- Migration 031: Reports V1 — Operational Reporting RPCs --- SECURITY DEFINER — bypasses RLS, enforces brand scoping in SQL --- All functions accept p_brand_id NULL = platform_admin sees all brands --- brand_admin is filtered at the page level via getAdminUser() - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. get_reports_summary — 10 KPI cards --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_reports_summary( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - -- Base WHERE: always filter by brand if provided, always filter by date - -- Status filter: exclude canceled orders from all revenue/order counts - - WITH date_orders AS ( - SELECT o.id, o.subtotal, o.status, o.brand_id, - CASE WHEN EXISTS ( - SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup' - ) THEN true ELSE false END AS has_pickup, - CASE WHEN EXISTS ( - SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping' - ) THEN true ELSE false END AS has_shipping - FROM orders o - WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date - AND o.status != 'canceled' - AND (p_brand_id IS NULL OR o.brand_id = p_brand_id) - ), - pickup_orders AS ( - SELECT COUNT(DISTINCT id) AS cnt FROM date_orders WHERE has_pickup - ), - shipping_orders AS ( - SELECT COUNT(DISTINCT id) AS cnt FROM date_orders WHERE has_shipping - ), - pending_pickups AS ( - SELECT COUNT(DISTINCT o.id) AS cnt - FROM date_orders o - WHERE o.has_pickup AND o.status = 'pending' - ), - completed_pickups AS ( - SELECT COUNT(DISTINCT o.id) AS cnt - FROM date_orders o - WHERE o.has_pickup AND o.status = 'completed' - ), - contacts_added AS ( - SELECT COUNT(*) AS cnt - FROM communication_contacts cc - WHERE cc.created_at::DATE BETWEEN p_start_date AND p_end_date - AND cc.source = 'import' - AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id) - ), - campaigns_sent AS ( - SELECT COUNT(*) AS cnt - FROM communication_campaigns cc - WHERE cc.sent_at::DATE BETWEEN p_start_date AND p_end_date - AND cc.status = 'sent' - AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id) - ), - messages_logged AS ( - SELECT COUNT(*) AS cnt - FROM communication_message_logs ml - WHERE ml.sent_at::DATE BETWEEN p_start_date AND p_end_date - AND (p_brand_id IS NULL OR ml.brand_id = p_brand_id) - ) - SELECT jsonb_build_object( - 'gross_sales', COALESCE(SUM(subtotal), 0), - 'total_orders', COUNT(DISTINCT id), - 'avg_order_value', CASE WHEN COUNT(id) > 0 THEN ROUND(AVG(subtotal), 2) ELSE 0 END, - 'pickup_orders', COALESCE((SELECT cnt FROM pickup_orders), 0), - 'shipping_orders', COALESCE((SELECT cnt FROM shipping_orders), 0), - 'pending_pickups', COALESCE((SELECT cnt FROM pending_pickups), 0), - 'completed_pickups', COALESCE((SELECT cnt FROM completed_pickups), 0), - 'contacts_added', COALESCE((SELECT cnt FROM contacts_added), 0), - 'campaigns_sent', COALESCE((SELECT cnt FROM campaigns_sent), 0), - 'messages_logged', COALESCE((SELECT cnt FROM messages_logged), 0) - ) INTO v_result - FROM date_orders; - - RETURN v_result; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. get_orders_by_stop_report — orders grouped by stop --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_orders_by_stop_report( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN COALESCE(jsonb_agg( - jsonb_build_object( - 'stop_name', r.stop_name, - 'city', r.city, - 'state', r.state, - 'date', r.date, - 'order_count', r.order_count, - 'gross_sales', r.gross_sales, - 'pending_count', r.pending_count, - 'completed_count', r.completed_count - ) ORDER BY r.date DESC - ), '[]'::JSONB) - FROM ( - SELECT - s.city || ', ' || s.state AS stop_name, - s.city, - s.state, - s.date, - COUNT(DISTINCT o.id) AS order_count, - COALESCE(SUM(o.subtotal), 0) AS gross_sales, - COUNT(DISTINCT CASE WHEN o.status = 'pending' THEN o.id END) AS pending_count, - COUNT(DISTINCT CASE WHEN o.status = 'completed' THEN o.id END) AS completed_count - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date - AND o.status != 'canceled' - AND (p_brand_id IS NULL OR o.brand_id = p_brand_id) - GROUP BY s.id, s.city, s.state, s.date - ) r; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. get_sales_by_product_report — product revenue --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_sales_by_product_report( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN COALESCE(jsonb_agg( - jsonb_build_object( - 'product_name', r.product_name, - 'units_sold', r.units_sold, - 'gross_revenue', r.gross_revenue, - 'avg_price', r.avg_price - ) ORDER BY r.gross_revenue DESC - ), '[]'::JSONB) - FROM ( - SELECT - p.name AS product_name, - SUM(oi.quantity) AS units_sold, - SUM(oi.price * oi.quantity) AS gross_revenue, - ROUND(AVG(oi.price), 2) AS avg_price - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - JOIN products p ON p.id = oi.product_id - WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date - AND o.status != 'canceled' - AND (p_brand_id IS NULL OR o.brand_id = p_brand_id) - GROUP BY p.id, p.name - ) r; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. get_fulfillment_report — pickup / shipping / mixed breakdown --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_fulfillment_report( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_total NUMERIC; -BEGIN - SELECT COALESCE(SUM(o.subtotal), 0) INTO v_total - FROM orders o - WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date - AND o.status != 'canceled' - AND (p_brand_id IS NULL OR o.brand_id = p_brand_id); - - RETURN COALESCE(jsonb_agg(sub ORDER BY revenue DESC), '[]'::JSONB) - FROM ( - SELECT - CASE - WHEN has_pickup AND has_shipping THEN 'mixed' - WHEN has_pickup THEN 'pickup' - ELSE 'shipping' - END AS fulfillment_type, - COUNT(DISTINCT o.id) AS order_count, - SUM(o.subtotal) AS revenue, - CASE WHEN v_total > 0 THEN ROUND(100.0 * SUM(o.subtotal) / v_total, 1) ELSE 0 END AS pct_of_total - FROM ( - SELECT o.id, o.subtotal, - EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup') AS has_pickup, - EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping') AS has_shipping - FROM orders o - WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date - AND o.status != 'canceled' - AND (p_brand_id IS NULL OR o.brand_id = p_brand_id) - ) o - GROUP BY fulfillment_type - ) sub; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 5. get_pickup_status_by_stop — per-stop pickup status --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_pickup_status_by_stop( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN COALESCE(jsonb_agg( - jsonb_build_object( - 'stop_name', r.stop_name, - 'city', r.city, - 'date', r.date, - 'total_orders', r.total_orders, - 'pending', r.pending, - 'completed', r.completed, - 'canceled', r.canceled - ) ORDER BY r.date DESC - ), '[]'::JSONB) - FROM ( - SELECT - s.city || ', ' || s.state AS stop_name, - s.city, - s.date, - COUNT(DISTINCT o.id) AS total_orders, - COUNT(DISTINCT CASE WHEN o.status = 'pending' THEN o.id END) AS pending, - COUNT(DISTINCT CASE WHEN o.status = 'completed' THEN o.id END) AS completed, - COUNT(DISTINCT CASE WHEN o.status = 'canceled' THEN o.id END) AS canceled - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date - AND EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup') - AND (p_brand_id IS NULL OR o.brand_id = p_brand_id) - GROUP BY s.id, s.city, s.state, s.date - ) r; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 6. get_contact_growth_report — new contacts over time --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_contact_growth_report( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN COALESCE(jsonb_agg( - jsonb_build_object( - 'date', r.date, - 'new_contacts', r.new_contacts, - 'imports', r.imports, - 'total', r.total - ) ORDER BY r.date DESC - ), '[]'::JSONB) - FROM ( - SELECT - d::DATE AS date, - COALESCE(SUM(CASE WHEN cc.source IS DISTINCT FROM 'import' THEN 1 ELSE 0 END), 0) AS new_contacts, - COALESCE(SUM(CASE WHEN cc.source = 'import' THEN 1 ELSE 0 END), 0) AS imports, - COUNT(cc.*) AS total - FROM generate_series(p_start_date, p_end_date, '1 day'::INTERVAL) d - LEFT JOIN communication_contacts cc - ON cc.created_at::DATE = d::DATE - AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id) - GROUP BY d::DATE - ) r; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 7. get_campaign_activity_report — campaign status and message counts --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_campaign_activity_report( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN COALESCE(jsonb_agg( - jsonb_build_object( - 'campaign_name', c.name, - 'status', c.status, - 'campaign_type', c.campaign_type, - 'sent_at', c.sent_at, - 'messages_logged', COALESCE(ml.sent_count, 0) - ) ORDER BY c.sent_at DESC NULLS LAST - ), '[]'::JSONB) - FROM communication_campaigns c - LEFT JOIN ( - SELECT campaign_id, COUNT(*) AS sent_count - FROM communication_message_logs - WHERE sent_at::DATE BETWEEN p_start_date AND p_end_date - GROUP BY campaign_id - ) ml ON ml.campaign_id = c.id - WHERE c.created_at::DATE BETWEEN p_start_date AND p_end_date - AND (p_brand_id IS NULL OR c.brand_id = p_brand_id); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- Refresh PostgREST schema cache --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/032_store_employee_role.sql b/supabase/migrations/.archived/032_store_employee_role.sql deleted file mode 100644 index 3c596bf..0000000 --- a/supabase/migrations/.archived/032_store_employee_role.sql +++ /dev/null @@ -1,177 +0,0 @@ --- Migration 032: Store Employee Role --- --- Add store_employee to the platform's role model. No new columns — --- admin_users.role already exists with values 'platform_admin', 'brand_admin'. --- store_employee just adds a third recognized role. --- --- RLS: store_employee can read orders for their brand (brand_id scoped). --- SQL RPCs: get_admin_orders + get_admin_order_detail add store_employee brand scoping. --- No changes to product/stop/communications/settings RLS — store_employee shouldn't --- be granted those tables in RLS at all (they go through existing SECURITY DEFINER RPCs --- with explicit can_manage_* guard checks in TypeScript actions). - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. RLS policy: store_employee can read their brand's orders --- ═══════════════════════════════════════════════════════════════════════════ - -DROP POLICY IF EXISTS "Store employee can read their brand orders" ON orders; -CREATE POLICY "Store employee can read their brand orders" - ON orders FOR SELECT TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'store_employee' - AND admin_users.brand_id = ( - SELECT brand_id FROM stops WHERE stops.id = orders.stop_id - ) - ) - ); - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. get_admin_orders — add store_employee brand scoping --- (SECURITY DEFINER, no RLS — add explicit brand_id check for store_employee) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION get_admin_orders(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_orders JSONB; - v_stops JSONB; - v_effective_brand_id UUID; -BEGIN - -- Resolve effective brand: use p_brand_id if non-null, - -- otherwise fall back to store_employee's own brand - IF p_brand_id IS NOT NULL THEN - v_effective_brand_id := p_brand_id; - ELSE - -- For store_employee with no p_brand_id, use their own brand_id - -- (caller must pass brand_id for store_employee to avoid cross-brand data) - v_effective_brand_id := NULL; - END IF; - - IF v_effective_brand_id IS NULL THEN - -- platform_admin or no brand scoping — return all orders - SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB) - INTO v_orders - FROM ( - SELECT o.id, o.customer_name, o.customer_email, o.customer_phone, - o.stop_id, o.status, o.subtotal, o.pickup_complete, - o.pickup_completed_at, o.pickup_completed_by, o.created_at, - CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) END as stops - FROM orders o LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.stop_id IS NOT NULL - LIMIT 500 - ) t; - - SELECT COALESCE(jsonb_agg(jsonb_build_object( - 'id', id, 'city', city, 'state', state, - 'date', date, 'time', time, 'location', location, 'brand_id', brand_id - ) ORDER BY date), '[]'::JSONB) - INTO v_stops FROM stops WHERE active = true; - ELSE - -- brand-scoped query (brand_admin, store_employee, or platform_admin filtering) - SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB) - INTO v_orders - FROM ( - SELECT o.id, o.customer_name, o.customer_email, o.customer_phone, - o.stop_id, o.status, o.subtotal, o.pickup_complete, - o.pickup_completed_at, o.pickup_completed_by, o.created_at, - jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) as stops - FROM orders o JOIN stops s ON o.stop_id = s.id - WHERE s.brand_id = v_effective_brand_id - LIMIT 500 - ) t; - - SELECT COALESCE(jsonb_agg(jsonb_build_object( - 'id', id, 'city', city, 'state', state, - 'date', date, 'time', time, 'location', location, 'brand_id', brand_id - ) ORDER BY date), '[]'::JSONB) - INTO v_stops FROM stops WHERE active = true AND brand_id = v_effective_brand_id; - END IF; - - RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. get_admin_order_detail — brand scoping (already SECURITY DEFINER, --- add check that store_employee can only view orders in their brand) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION get_admin_order_detail(p_order_id UUID, p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_order JSONB; - v_order_brand_id UUID; - v_stop_brand_id UUID; -BEGIN - -- Resolve order's brand_id (from order.brand_id or stop.brand_id) - SELECT - COALESCE(o.brand_id, s.brand_id), - s.brand_id - INTO v_order_brand_id, v_stop_brand_id - FROM orders o - LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.id = p_order_id; - - -- Brand scoping: if p_brand_id is provided, restrict to that brand - IF p_brand_id IS NOT NULL AND v_order_brand_id IS NOT NULL AND v_order_brand_id != p_brand_id THEN - RETURN jsonb_build_object('error', 'Order not found or access denied'); - END IF; - - SELECT jsonb_build_object( - 'id', o.id, - 'customer_name', o.customer_name, - 'customer_email', o.customer_email, - 'customer_phone', o.customer_phone, - 'stop_id', o.stop_id, - 'status', o.status, - 'subtotal', o.subtotal, - 'pickup_complete', o.pickup_complete, - 'pickup_completed_at', o.pickup_completed_at, - 'pickup_completed_by', o.pickup_completed_by, - 'created_at', o.created_at, - 'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) END, - 'order_items', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'id', oi.id, 'product_id', oi.product_id, 'product_name', p.name, - 'quantity', oi.quantity, 'price', oi.price, 'fulfillment', oi.fulfillment, - 'products', jsonb_build_object('name', p.name) - )) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id - ), '[]'::JSONB) - ) - INTO v_order - FROM orders o - LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.id = p_order_id; - - RETURN v_order; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. Refresh PostgREST schema cache --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/033_admin_users_crud.sql b/supabase/migrations/.archived/033_admin_users_crud.sql deleted file mode 100644 index 6ecdeca..0000000 --- a/supabase/migrations/.archived/033_admin_users_crud.sql +++ /dev/null @@ -1,384 +0,0 @@ --- Migration 033: Admin Users CRUD --- --- RPC functions for listing, creating, updating, and deactivating admin_users. --- Security constraints enforced at SQL level: --- - platform_admin: can manage all users and any brand --- - brand_admin with can_manage_users: can only manage users within their brand, --- cannot create platform_admin, cannot assign foreign brand_id --- - store_employee: cannot access these functions (guarded at TS route level) --- --- Display name: joins auth.users raw_user_meta_data -> display_name | name, falls back to email. - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. get_admin_users — list users, optionally filtered by brand --- ═══════════════════════════════════════════════════════════════════════════ - -DROP FUNCTION IF EXISTS get_admin_users(UUID); -CREATE OR REPLACE FUNCTION get_admin_users(p_brand_id UUID DEFAULT NULL) -RETURNS TABLE ( - id UUID, - user_id UUID, - display_name TEXT, - email TEXT, - role TEXT, - brand_id UUID, - brand_name TEXT, - can_manage_products BOOLEAN, - can_manage_stops BOOLEAN, - can_manage_orders BOOLEAN, - can_manage_pickup BOOLEAN, - can_manage_messages BOOLEAN, - can_manage_refunds BOOLEAN, - can_manage_users BOOLEAN, - can_manage_water_log BOOLEAN, - can_manage_reports BOOLEAN, - active BOOLEAN, - created_at TIMESTAMPTZ, - last_login TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN QUERY - SELECT - au.id, - au.user_id, - COALESCE( - (au.raw_user_meta_data->>'display_name')::TEXT, - (au.raw_user_meta_data->>'full_name')::TEXT, - u.email - ) AS display_name, - u.email, - au.role::TEXT, - au.brand_id, - b.name AS brand_name, - au.can_manage_products, - au.can_manage_stops, - au.can_manage_orders, - au.can_manage_pickup, - au.can_manage_messages, - au.can_manage_refunds, - au.can_manage_users, - au.can_manage_water_log, - COALESCE(au.can_manage_reports, false) AS can_manage_reports, - au.active, - au.created_at, - au.last_login - FROM admin_users au - JOIN auth.users u ON au.user_id = u.id - LEFT JOIN brands b ON au.brand_id = b.id - WHERE - -- platform_admin sees all; brand_admin sees only their brand - ( - EXISTS ( - SELECT 1 FROM admin_users caller - WHERE caller.user_id = auth.uid() - AND caller.role = 'platform_admin' - ) - OR - ( - EXISTS ( - SELECT 1 FROM admin_users caller - WHERE caller.user_id = auth.uid() - AND caller.role = 'brand_admin' - AND caller.can_manage_users = true - ) - AND (p_brand_id IS NULL OR au.brand_id = p_brand_id) - AND ( - EXISTS ( - SELECT 1 FROM admin_users caller - WHERE caller.user_id = auth.uid() - AND caller.brand_id = au.brand_id - ) - ) - ) - ) - ORDER BY au.created_at DESC; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. create_admin_user — create a new admin user record --- --- p_email: must already have a Supabase auth account --- p_role: 'platform_admin' | 'brand_admin' | 'store_employee' --- p_brand_id: required for brand_admin and store_employee --- p_flags: JSONB with individual can_manage_* booleans --- ═══════════════════════════════════════════════════════════════════════════ - -DROP FUNCTION IF EXISTS create_admin_user(TEXT, TEXT, UUID, JSONB); -CREATE OR REPLACE FUNCTION create_admin_user( - p_email TEXT, - p_role TEXT, - p_brand_id UUID DEFAULT NULL, - p_flags JSONB DEFAULT '{}'::JSONB -) -RETURNS TABLE ( - id UUID, - user_id UUID, - display_name TEXT, - email TEXT, - role TEXT, - brand_id UUID, - active BOOLEAN, - created_at TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_caller_role TEXT; - v_caller_brand_id UUID; - v_caller_can_manage_users BOOLEAN; - v_target_user_id UUID; - v_new_id UUID; -BEGIN - -- Caller must be authenticated - IF auth.uid() IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - -- Look up caller - SELECT role, brand_id, can_manage_users - INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users - FROM admin_users - WHERE user_id = auth.uid(); - - IF v_caller_role IS NULL THEN - RAISE EXCEPTION 'Not an admin'; - END IF; - - -- Security: brand_admin cannot create platform_admin - IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN - RAISE EXCEPTION 'Insufficient permissions to create a platform_admin'; - END IF; - - -- Security: brand_admin can only create users in their own brand - IF v_caller_role = 'brand_admin' THEN - IF p_brand_id IS DISTINCT FROM v_caller_brand_id THEN - RAISE EXCEPTION 'Insufficient permissions to create a user for another brand'; - END IF; - IF p_brand_id IS NULL AND p_role = 'platform_admin' THEN - RAISE EXCEPTION 'brand_admin cannot create platform_admin'; - END IF; - END IF; - - -- Find the auth user by email - BEGIN - SELECT id INTO v_target_user_id FROM auth.users WHERE email = p_email; - EXCEPTION WHEN OTHERS THEN - RAISE EXCEPTION 'Auth user with email % not found', p_email; - END; - - IF v_target_user_id IS NULL THEN - RAISE EXCEPTION 'Auth user with email % not found', p_email; - END IF; - - -- Check no existing admin_users record for this user - IF EXISTS (SELECT 1 FROM admin_users WHERE user_id = v_target_user_id) THEN - RAISE EXCEPTION 'Admin user with email % already exists', p_email; - END IF; - - -- Insert new admin user - INSERT INTO admin_users ( - user_id, role, brand_id, - can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup, - can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, - can_manage_reports, active - ) VALUES ( - v_target_user_id, p_role, p_brand_id, - COALESCE((p_flags->>'can_manage_products')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_users')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, false), - true - ) - RETURNING id INTO v_new_id; - - RETURN QUERY - SELECT - au.id, au.user_id, - COALESCE( - (au.raw_user_meta_data->>'display_name')::TEXT, - (au.raw_user_meta_data->>'full_name')::TEXT, - u.email - ) AS display_name, - u.email, au.role::TEXT, au.brand_id, au.active, au.created_at - FROM admin_users au - JOIN auth.users u ON au.user_id = u.id - WHERE au.id = v_new_id; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. update_admin_user — update role, brand, flags, or active status --- ═══════════════════════════════════════════════════════════════════════════ - -DROP FUNCTION IF EXISTS update_admin_user(UUID, TEXT, UUID, JSONB, BOOLEAN); -CREATE OR REPLACE FUNCTION update_admin_user( - p_id UUID, - p_role TEXT DEFAULT NULL, - p_brand_id UUID DEFAULT NULL, - p_flags JSONB DEFAULT NULL, - p_active BOOLEAN DEFAULT NULL -) -RETURNS TABLE ( - id UUID, - user_id UUID, - display_name TEXT, - email TEXT, - role TEXT, - brand_id UUID, - active BOOLEAN, - updated_at TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_caller_role TEXT; - v_caller_brand_id UUID; - v_caller_can_manage_users BOOLEAN; - v_target_role TEXT; - v_target_brand_id UUID; -BEGIN - IF auth.uid() IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT role, brand_id, can_manage_users - INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users - FROM admin_users - WHERE user_id = auth.uid(); - - IF v_caller_role IS NULL THEN - RAISE EXCEPTION 'Not an admin'; - END IF; - - -- Cannot update your own account's role - IF EXISTS (SELECT 1 FROM admin_users WHERE id = p_id AND user_id = auth.uid()) THEN - RAISE EXCEPTION 'Cannot update your own admin account'; - END IF; - - -- Look up target - SELECT role, brand_id INTO v_target_role, v_target_brand_id - FROM admin_users WHERE id = p_id; - - IF v_target_role IS NULL THEN - RAISE EXCEPTION 'Admin user not found'; - END IF; - - -- Security: brand_admin cannot demote/promote platform_admin - IF v_caller_role = 'brand_admin' AND v_target_role = 'platform_admin' AND p_role IS NOT NULL THEN - RAISE EXCEPTION 'Insufficient permissions to modify a platform_admin'; - END IF; - - -- Security: brand_admin cannot give platform_admin role - IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN - RAISE EXCEPTION 'Insufficient permissions to create a platform_admin'; - END IF; - - -- Security: brand_admin can only affect users in their brand - IF v_caller_role = 'brand_admin' THEN - IF v_target_brand_id != v_caller_brand_id THEN - RAISE EXCEPTION 'Insufficient permissions to modify a user from another brand'; - END IF; - IF p_brand_id IS NOT NULL AND p_brand_id != v_caller_brand_id THEN - RAISE EXCEPTION 'Insufficient permissions to assign a user to another brand'; - END IF; - IF p_role = 'platform_admin' THEN - RAISE EXCEPTION 'Insufficient permissions to create a platform_admin'; - END IF; - END IF; - - -- Apply updates - UPDATE admin_users SET - role = COALESCE(p_role, role), - brand_id = COALESCE(p_brand_id, brand_id), - can_manage_products = COALESCE((p_flags->>'can_manage_products')::BOOLEAN, can_manage_products), - can_manage_stops = COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, can_manage_stops), - can_manage_orders = COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, can_manage_orders), - can_manage_pickup = COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, can_manage_pickup), - can_manage_messages = COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, can_manage_messages), - can_manage_refunds = COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, can_manage_refunds), - can_manage_users = COALESCE((p_flags->>'can_manage_users')::BOOLEAN, can_manage_users), - can_manage_water_log = COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, can_manage_water_log), - can_manage_reports = COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, can_manage_reports), - active = COALESCE(p_active, active) - WHERE id = p_id; - - RETURN QUERY - SELECT - au.id, au.user_id, - COALESCE( - (au.raw_user_meta_data->>'display_name')::TEXT, - (au.raw_user_meta_data->>'full_name')::TEXT, - u.email - ) AS display_name, - u.email, au.role::TEXT, au.brand_id, au.active, NOW() AS updated_at - FROM admin_users au - JOIN auth.users u ON au.user_id = u.id - WHERE au.id = p_id; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. delete_admin_user — remove admin user record --- Cannot delete your own account. --- ═══════════════════════════════════════════════════════════════════════════ - -DROP FUNCTION IF EXISTS delete_admin_user(UUID); -CREATE OR REPLACE FUNCTION delete_admin_user(p_id UUID) -RETURNS BOOLEAN -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_caller_role TEXT; - v_target_user_id UUID; -BEGIN - IF auth.uid() IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT role INTO v_caller_role FROM admin_users WHERE user_id = auth.uid(); - IF v_caller_role IS NULL THEN - RAISE EXCEPTION 'Not an admin'; - END IF; - - -- Cannot delete self - SELECT user_id INTO v_target_user_id FROM admin_users WHERE id = p_id; - IF v_target_user_id = auth.uid() THEN - RAISE EXCEPTION 'Cannot delete your own admin account'; - END IF; - - -- brand_admin can only delete users in their brand - IF v_caller_role = 'brand_admin' THEN - IF NOT EXISTS ( - SELECT 1 FROM admin_users - WHERE id = p_id AND brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid()) - ) THEN - RAISE EXCEPTION 'Insufficient permissions to delete this user'; - END IF; - END IF; - - DELETE FROM admin_users WHERE id = p_id; - RETURN true; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 5. Refresh PostgREST schema cache --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/034_password_change_flow.sql b/supabase/migrations/.archived/034_password_change_flow.sql deleted file mode 100644 index 7e409d5..0000000 --- a/supabase/migrations/.archived/034_password_change_flow.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Migration: 034_password_change_flow --- Adds must_change_password column to admin_users if not exists --- Adds phone_number column to admin_users if not exists --- Creates RPC to clear must_change_password after password update - -alter table admin_users add column if not exists must_change_password boolean not null default false; -alter table admin_users add column if not exists phone_number text; - --- RPC: clear must_change_password for the authenticated user -create or replace function clear_must_change_password() -returns boolean -language plpgsql -security definer set search_path = public -as $$ -begin - update admin_users - set must_change_password = false - where user_id = auth.uid() - and must_change_password = true; - - return true; -end; -$$; diff --git a/supabase/migrations/.archived/035_admin_action_logs.sql b/supabase/migrations/.archived/035_admin_action_logs.sql deleted file mode 100644 index 4a6f4ae..0000000 --- a/supabase/migrations/.archived/035_admin_action_logs.sql +++ /dev/null @@ -1,106 +0,0 @@ --- Migration: 035_admin_action_logs --- Dedicated audit trail for admin user management actions - -CREATE TABLE IF NOT EXISTS admin_action_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - action_type TEXT NOT NULL, -- 'create' | 'update' | 'delete' - admin_id UUID, -- auth.uid() of the admin performing the action - admin_email TEXT, - affected_user_id UUID, -- the admin_users.id being modified - brand_id UUID, - details JSONB DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Index for fast lookups by acting admin -CREATE INDEX IF NOT EXISTS idx_admin_action_logs_admin_id ON admin_action_logs(admin_id); --- Index for fast lookups by affected user -CREATE INDEX IF NOT EXISTS idx_admin_action_logs_affected_user ON admin_action_logs(affected_user_id); --- Index for time-based queries -CREATE INDEX IF NOT EXISTS idx_admin_action_logs_created_at ON admin_action_logs(created_at DESC); - --- RLS: platform_admin can read all; brand_admin reads only their brand -ALTER TABLE admin_action_logs ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "platform_admin_read_all_admin_action_logs" ON admin_action_logs - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - ) - ); - -CREATE POLICY "brand_admin_read_own_brand_admin_action_logs" ON admin_action_logs - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'brand_admin' - AND au.brand_id = admin_action_logs.brand_id - ) - ); - --- Append-only: any authenticated admin user can insert (enforced at RPC level) -CREATE POLICY "admin_action_logs_insert" ON admin_action_logs - FOR INSERT WITH CHECK (auth.uid() IS NOT NULL); - --- ============================================================ --- log_admin_action — SECURITY DEFINER, bypasses RLS --- Called by admin user CRUD actions to record what changed. --- Payload: { action_type, admin_id, admin_email, affected_user_id, brand_id, details } --- ============================================================ -CREATE OR REPLACE FUNCTION log_admin_action(p_payload JSONB) -RETURNS UUID -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_log_id UUID; -BEGIN - INSERT INTO admin_action_logs ( - action_type, admin_id, admin_email, affected_user_id, brand_id, details - ) VALUES ( - p_payload->>'action_type', - CASE WHEN (p_payload->>'admin_id') IS NULL THEN NULL ELSE (p_payload->>'admin_id')::UUID END, - p_payload->>'admin_email', - CASE WHEN (p_payload->>'affected_user_id') IS NULL THEN NULL ELSE (p_payload->>'affected_user_id')::UUID END, - CASE WHEN (p_payload->>'brand_id') IS NULL THEN NULL ELSE (p_payload->>'brand_id')::UUID END, - COALESCE(p_payload->'details', '{}'::JSONB) - ) - RETURNING id INTO v_log_id; - - RETURN v_log_id; -END; -$$; - --- ============================================================ --- log_user_activity — SECURITY DEFINER, bypasses RLS --- Tracks per-user activities: logins, password changes, profile updates. --- Payload: { user_id, activity_type, details, ip_address, user_agent } --- ============================================================ -CREATE OR REPLACE FUNCTION log_user_activity(p_payload JSONB) -RETURNS UUID -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_log_id UUID; -BEGIN - INSERT INTO user_activity_logs ( - user_id, activity_type, details, ip_address, user_agent - ) VALUES ( - CASE WHEN (p_payload->>'user_id') IS NULL THEN NULL ELSE (p_payload->>'user_id')::UUID END, - p_payload->>'activity_type', - COALESCE(p_payload->'details', '{}'::JSONB), - p_payload->>'ip_address', - p_payload->>'user_agent' - ) - RETURNING id INTO v_log_id; - - RETURN v_log_id; -END; -$$; - diff --git a/supabase/migrations/.archived/036_user_activity_logs.sql b/supabase/migrations/.archived/036_user_activity_logs.sql deleted file mode 100644 index fb53c65..0000000 --- a/supabase/migrations/.archived/036_user_activity_logs.sql +++ /dev/null @@ -1,36 +0,0 @@ --- Migration: 036_user_activity_logs --- Tracks per-user activities: logins, password changes, profile updates - -CREATE TABLE IF NOT EXISTS user_activity_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL, - activity_type TEXT NOT NULL, -- 'login' | 'logout' | 'password_change' | 'profile_update' | 'email_change' - details JSONB DEFAULT '{}', - ip_address TEXT, - user_agent TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Index for fast per-user log lookups -CREATE INDEX IF NOT EXISTS idx_user_activity_logs_user_id ON user_activity_logs(user_id); --- Index for time-based queries per user -CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at DESC); - --- RLS: user can read own logs; platform_admin can read all -ALTER TABLE user_activity_logs ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "user_read_own_activity_logs" ON user_activity_logs - FOR SELECT USING (auth.uid() = user_id); - -CREATE POLICY "platform_admin_read_all_user_activity_logs" ON user_activity_logs - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - ) - ); - --- Any authenticated user can insert their own log entries -CREATE POLICY "user_insert_own_activity_logs" ON user_activity_logs - FOR INSERT WITH CHECK (auth.uid() = user_id); diff --git a/supabase/migrations/.archived/037_update_admin_user_profile.sql b/supabase/migrations/.archived/037_update_admin_user_profile.sql deleted file mode 100644 index 0a92bc4..0000000 --- a/supabase/migrations/.archived/037_update_admin_user_profile.sql +++ /dev/null @@ -1,334 +0,0 @@ --- Migration: 037_update_admin_user_profile --- Extends update_admin_user to accept display_name and phone_number. --- Updates admin_users and syncs auth.users raw_user_meta_data. --- After update, writes to admin_action_logs. - -DROP FUNCTION IF EXISTS update_admin_user(UUID, TEXT, UUID, JSONB, BOOLEAN, TEXT, TEXT); -CREATE OR REPLACE FUNCTION update_admin_user( - p_id UUID, - p_role TEXT DEFAULT NULL, - p_brand_id UUID DEFAULT NULL, - p_flags JSONB DEFAULT NULL, - p_active BOOLEAN DEFAULT NULL, - p_display_name TEXT DEFAULT NULL, - p_phone_number TEXT DEFAULT NULL -) -RETURNS TABLE ( - id UUID, - user_id UUID, - display_name TEXT, - email TEXT, - role TEXT, - brand_id UUID, - active BOOLEAN, - phone_number TEXT, - updated_at TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_caller_role TEXT; - v_caller_brand_id UUID; - v_caller_can_manage_users BOOLEAN; - v_target_role TEXT; - v_target_brand_id UUID; - v_target_user_id UUID; - v_admin_email TEXT; -BEGIN - IF auth.uid() IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT role, brand_id, can_manage_users - INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users - FROM admin_users - WHERE user_id = auth.uid(); - - IF v_caller_role IS NULL THEN - RAISE EXCEPTION 'Not an admin'; - END IF; - - -- Cannot update your own account's role - IF EXISTS (SELECT 1 FROM admin_users WHERE id = p_id AND user_id = auth.uid()) THEN - RAISE EXCEPTION 'Cannot update your own admin account'; - END IF; - - -- Look up target - SELECT role, brand_id, user_id INTO v_target_role, v_target_brand_id, v_target_user_id - FROM admin_users WHERE id = p_id; - - IF v_target_role IS NULL THEN - RAISE EXCEPTION 'Admin user not found'; - END IF; - - -- Security: brand_admin cannot demote/promote platform_admin - IF v_caller_role = 'brand_admin' AND v_target_role = 'platform_admin' AND p_role IS NOT NULL THEN - RAISE EXCEPTION 'Insufficient permissions to modify a platform_admin'; - END IF; - - -- Security: brand_admin cannot give platform_admin role - IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN - RAISE EXCEPTION 'Insufficient permissions to create a platform_admin'; - END IF; - - -- Security: brand_admin can only affect users in their brand - IF v_caller_role = 'brand_admin' THEN - IF v_target_brand_id != v_caller_brand_id THEN - RAISE EXCEPTION 'Insufficient permissions to modify a user from another brand'; - END IF; - IF p_brand_id IS NOT NULL AND p_brand_id != v_caller_brand_id THEN - RAISE EXCEPTION 'Insufficient permissions to assign a user to another brand'; - END IF; - IF p_role = 'platform_admin' THEN - RAISE EXCEPTION 'Insufficient permissions to create a platform_admin'; - END IF; - END IF; - - -- Get caller email for audit log - SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid(); - - -- Apply updates to admin_users - UPDATE admin_users SET - role = COALESCE(p_role, role), - brand_id = COALESCE(p_brand_id, brand_id), - can_manage_products = COALESCE((p_flags->>'can_manage_products')::BOOLEAN, can_manage_products), - can_manage_stops = COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, can_manage_stops), - can_manage_orders = COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, can_manage_orders), - can_manage_pickup = COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, can_manage_pickup), - can_manage_messages = COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, can_manage_messages), - can_manage_refunds = COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, can_manage_refunds), - can_manage_users = COALESCE((p_flags->>'can_manage_users')::BOOLEAN, can_manage_users), - can_manage_water_log = COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, can_manage_water_log), - can_manage_reports = COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, can_manage_reports), - active = COALESCE(p_active, active), - display_name = COALESCE(p_display_name, display_name), - phone_number = COALESCE(p_phone_number, phone_number) - WHERE id = p_id; - - -- Sync display_name / phone_number to auth.users raw_user_meta_data - IF p_display_name IS NOT NULL OR p_phone_number IS NOT NULL THEN - UPDATE auth.users SET - raw_user_meta_data = jsonb_strip_nulls( - jsonb_set( - COALESCE(raw_user_meta_data, '{}'::jsonb), - '{display_name}', - to_jsonb(p_display_name) - ) || - jsonb_set( - COALESCE(raw_user_meta_data, '{}'::jsonb), - '{phone_number}', - to_jsonb(p_phone_number) - ) - ) - WHERE id = v_target_user_id; - END IF; - - -- Audit log: record the action - PERFORM log_admin_action(jsonb_build_object( - 'action_type', 'update', - 'admin_id', auth.uid(), - 'admin_email', v_admin_email, - 'affected_user_id', v_target_user_id, - 'brand_id', COALESCE(p_brand_id, v_target_brand_id), - 'details', jsonb_build_object( - 'changed_fields', ARRAY_REMOVE(ARRAY[ - CASE WHEN p_role IS NOT NULL THEN 'role' ELSE NULL END, - CASE WHEN p_brand_id IS NOT NULL THEN 'brand_id' ELSE NULL END, - CASE WHEN p_flags IS NOT NULL THEN 'flags' ELSE NULL END, - CASE WHEN p_active IS NOT NULL THEN 'active' ELSE NULL END, - CASE WHEN p_display_name IS NOT NULL THEN 'display_name' ELSE NULL END, - CASE WHEN p_phone_number IS NOT NULL THEN 'phone_number' ELSE NULL END - ], NULL) - ) - )); - - RETURN QUERY - SELECT - au.id, au.user_id, - COALESCE( - (au.raw_user_meta_data->>'display_name')::TEXT, - u.email - ) AS display_name, - u.email, au.role::TEXT, au.brand_id, au.active, - au.phone_number, NOW() AS updated_at - FROM admin_users au - JOIN auth.users u ON au.user_id = u.id - WHERE au.id = p_id; -END; -$$; - --- Update create_admin_user to also log -CREATE OR REPLACE FUNCTION create_admin_user( - p_email TEXT, - p_role TEXT, - p_brand_id UUID DEFAULT NULL, - p_flags JSONB DEFAULT '{}'::JSONB -) -RETURNS TABLE ( - id UUID, - user_id UUID, - display_name TEXT, - email TEXT, - role TEXT, - brand_id UUID, - active BOOLEAN, - created_at TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_caller_role TEXT; - v_caller_brand_id UUID; - v_caller_can_manage_users BOOLEAN; - v_target_user_id UUID; - v_new_id UUID; - v_admin_email TEXT; -BEGIN - IF auth.uid() IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT role, brand_id, can_manage_users - INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users - FROM admin_users - WHERE user_id = auth.uid(); - - IF v_caller_role IS NULL THEN - RAISE EXCEPTION 'Not an admin'; - END IF; - - IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN - RAISE EXCEPTION 'Insufficient permissions to create a platform_admin'; - END IF; - - IF v_caller_role = 'brand_admin' THEN - IF p_brand_id IS DISTINCT FROM v_caller_brand_id THEN - RAISE EXCEPTION 'Insufficient permissions to create a user for another brand'; - END IF; - IF p_brand_id IS NULL AND p_role = 'platform_admin' THEN - RAISE EXCEPTION 'brand_admin cannot create platform_admin'; - END IF; - END IF; - - BEGIN - SELECT id INTO v_target_user_id FROM auth.users WHERE email = p_email; - EXCEPTION WHEN OTHERS THEN - RAISE EXCEPTION 'Auth user with email % not found', p_email; - END; - - IF v_target_user_id IS NULL THEN - RAISE EXCEPTION 'Auth user with email % not found', p_email; - END IF; - - IF EXISTS (SELECT 1 FROM admin_users WHERE user_id = v_target_user_id) THEN - RAISE EXCEPTION 'Admin user with email % already exists', p_email; - END IF; - - -- Get caller email for audit log - SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid(); - - INSERT INTO admin_users ( - user_id, role, brand_id, - can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup, - can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, - can_manage_reports, active - ) VALUES ( - v_target_user_id, p_role, p_brand_id, - COALESCE((p_flags->>'can_manage_products')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_users')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, false), - true - ) - RETURNING id INTO v_new_id; - - -- Audit log: record the creation - PERFORM log_admin_action(jsonb_build_object( - 'action_type', 'create', - 'admin_id', auth.uid(), - 'admin_email', v_admin_email, - 'affected_user_id', v_target_user_id, - 'brand_id', p_brand_id, - 'details', jsonb_build_object('new_role', p_role) - )); - - RETURN QUERY - SELECT - au.id, au.user_id, - COALESCE( - (au.raw_user_meta_data->>'display_name')::TEXT, - (au.raw_user_meta_data->>'full_name')::TEXT, - u.email - ) AS display_name, - u.email, au.role::TEXT, au.brand_id, au.active, au.created_at - FROM admin_users au - JOIN auth.users u ON au.user_id = u.id - WHERE au.id = v_new_id; -END; -$$; - --- Update delete_admin_user to also log -CREATE OR REPLACE FUNCTION delete_admin_user(p_id UUID) -RETURNS BOOLEAN -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_caller_role TEXT; - v_target_user_id UUID; - v_admin_email TEXT; - v_target_brand_id UUID; -BEGIN - IF auth.uid() IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT role INTO v_caller_role FROM admin_users WHERE user_id = auth.uid(); - IF v_caller_role IS NULL THEN - RAISE EXCEPTION 'Not an admin'; - END IF; - - SELECT user_id, brand_id INTO v_target_user_id, v_target_brand_id - FROM admin_users WHERE id = p_id; - - SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid(); - - IF v_target_user_id = auth.uid() THEN - RAISE EXCEPTION 'Cannot delete your own admin account'; - END IF; - - IF v_caller_role = 'brand_admin' THEN - IF NOT EXISTS ( - SELECT 1 FROM admin_users - WHERE id = p_id AND brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid()) - ) THEN - RAISE EXCEPTION 'Insufficient permissions to delete this user'; - END IF; - END IF; - - -- Audit log: record the deletion - PERFORM log_admin_action(jsonb_build_object( - 'action_type', 'delete', - 'admin_id', auth.uid(), - 'admin_email', v_admin_email, - 'affected_user_id', v_target_user_id, - 'brand_id', v_target_brand_id, - 'details', jsonb_build_object('deleted_admin_id', p_id) - )); - - DELETE FROM admin_users WHERE id = p_id; - RETURN true; -END; -$$; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/038_product_images.sql b/supabase/migrations/.archived/038_product_images.sql deleted file mode 100644 index 0e19553..0000000 --- a/supabase/migrations/.archived/038_product_images.sql +++ /dev/null @@ -1,31 +0,0 @@ --- Migration 038: Product Images + Public Site Polish --- Idempotent: ADD COLUMN IF NOT EXISTS, CREATE OR REPLACE FUNCTION - --- ── 1. Add image_url to products ─────────────────────────────────────────────── -ALTER TABLE products ADD COLUMN IF NOT EXISTS image_url TEXT; - --- ── 2. Update get_stop_products to include image_url ────────────────────────── -CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN jsonb_build_object('products', ( - SELECT COALESCE(jsonb_agg( - jsonb_build_object( - 'id', ps.id, - 'product_id', ps.product_id, - 'name', p.name, - 'type', p.type, - 'price', p.price, - 'image_url', p.image_url - ) - ), '[]'::JSONB) - FROM product_stops ps - JOIN products p ON p.id = ps.product_id - WHERE ps.stop_id = p_stop_id - )); -END; -$$; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/039_shipping_fulfillment.sql b/supabase/migrations/.archived/039_shipping_fulfillment.sql deleted file mode 100644 index 5ac89b4..0000000 --- a/supabase/migrations/.archived/039_shipping_fulfillment.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Migration 039: Shipping Fulfillment Fields --- Idempotent: ADD COLUMN IF NOT EXISTS - -ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_status TEXT NOT NULL DEFAULT 'pending'; -ALTER TABLE orders ADD COLUMN IF NOT EXISTS tracking_number TEXT; - -COMMENT ON COLUMN orders.shipping_status IS 'pending | label_created | shipped | delivered | returned'; -COMMENT ON COLUMN orders.tracking_number IS 'Carrier tracking number'; diff --git a/supabase/migrations/.archived/040_shipping_fulfillment_rpcs.sql b/supabase/migrations/.archived/040_shipping_fulfillment_rpcs.sql deleted file mode 100644 index 3e1a042..0000000 --- a/supabase/migrations/.archived/040_shipping_fulfillment_rpcs.sql +++ /dev/null @@ -1,86 +0,0 @@ --- Migration 040: Shipping Fulfillment RPCs --- Idempotent: CREATE OR REPLACE FUNCTION - --- ── 1. update_shipping_order ────────────────────────────────────────────────── --- Updates shipping_status and tracking_number for an order. --- Requires can_manage_orders permission (checked in server action via getAdminUser). - -CREATE OR REPLACE FUNCTION public.update_shipping_order( - p_order_id UUID, - p_shipping_status TEXT, - p_tracking_number TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE orders SET - shipping_status = p_shipping_status, - tracking_number = p_tracking_number, - updated_at = now() - WHERE id = p_order_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Order not found'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── 2. get_shipping_orders ─────────────────────────────────────────────────── --- Returns orders that contain at least one shipping-line item. --- Filtered by brand_id when p_brand_id is provided. - -CREATE OR REPLACE FUNCTION public.get_shipping_orders(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg( - jsonb_build_object( - 'id', o.id, - 'customer_name', o.customer_name, - 'customer_email', o.customer_email, - 'customer_phone', o.customer_phone, - 'status', o.status, - 'subtotal', o.subtotal, - 'shipping_status', o.shipping_status, - 'tracking_number', o.tracking_number, - 'created_at', o.created_at, - 'brand_id', o.brand_id, - 'order_items', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'id', oi.id, - 'product_id', oi.product_id, - 'quantity', oi.quantity, - 'price', oi.price, - 'fulfillment', oi.fulfillment, - 'products', jsonb_build_object('name', p.name) - )) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping' - ), '[]'::JSONB) - ) - ) - INTO v_result - FROM orders o - WHERE o.id IN ( - SELECT DISTINCT oi.order_id - FROM order_items oi - WHERE oi.fulfillment = 'shipping' - ) - AND ( - p_brand_id IS NULL - OR o.brand_id = p_brand_id - ) - ORDER BY o.created_at DESC; - - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/041_payment_settings.sql b/supabase/migrations/.archived/041_payment_settings.sql deleted file mode 100644 index 0d726b3..0000000 --- a/supabase/migrations/.archived/041_payment_settings.sql +++ /dev/null @@ -1,105 +0,0 @@ --- Migration 041: Payment Settings --- Creates payment_settings table and RPCs for provider configuration. - --- ── 1. payment_settings table ───────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.payment_settings ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - provider TEXT, -- stripe | square | manual - stripe_publishable_key TEXT, - stripe_secret_key TEXT, - square_access_token TEXT, - square_location_id TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE(brand_id) -); - --- ── 2. RLS ────────────────────────────────────────────────────────────────── -ALTER TABLE public.payment_settings ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Brand admin can read payment_settings" ON public.payment_settings; -CREATE POLICY "Brand admin can read payment_settings" - ON public.payment_settings FOR SELECT TO authenticated - USING ( - brand_id IN ( - SELECT brand_id FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'brand_admin' - ) - ); - -DROP POLICY IF EXISTS "Platform admin can read payment_settings" ON public.payment_settings; -CREATE POLICY "Platform admin can read payment_settings" - ON public.payment_settings FOR SELECT TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - ) - ); - --- Writes go through SECURITY DEFINER helpers only. - --- ── 3. get_payment_settings ────────────────────────────────────────────────── -CREATE OR REPLACE FUNCTION public.get_payment_settings(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_build_object( - 'id', id, - 'brand_id', brand_id, - 'provider', provider, - 'stripe_publishable_key', stripe_publishable_key, - 'stripe_secret_key', stripe_secret_key, - 'square_access_token', square_access_token, - 'square_location_id', square_location_id, - 'updated_at', updated_at::TEXT - ) INTO v_result - FROM payment_settings - WHERE brand_id = p_brand_id; - - RETURN v_result; -END; -$$; - --- ── 4. upsert_payment_settings ──────────────────────────────────────────────── -CREATE OR REPLACE FUNCTION public.upsert_payment_settings( - p_brand_id UUID, - p_provider TEXT, - p_stripe_publishable_key TEXT DEFAULT NULL, - p_stripe_secret_key TEXT DEFAULT NULL, - p_square_access_token TEXT DEFAULT NULL, - p_square_location_id TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO payment_settings ( - brand_id, provider, - stripe_publishable_key, stripe_secret_key, - square_access_token, square_location_id - ) - VALUES ( - p_brand_id, p_provider, - p_stripe_publishable_key, p_stripe_secret_key, - p_square_access_token, p_square_location_id - ) - ON CONFLICT (brand_id) DO UPDATE SET - provider = EXCLUDED.provider, - stripe_publishable_key = EXCLUDED.stripe_publishable_key, - stripe_secret_key = EXCLUDED.stripe_secret_key, - square_access_token = EXCLUDED.square_access_token, - square_location_id = EXCLUDED.square_location_id, - updated_at = now(); - - RETURN jsonb_build_object('success', true); -END; -$$; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/042_product_import_rpc.sql b/supabase/migrations/.archived/042_product_import_rpc.sql deleted file mode 100644 index f6b9e27..0000000 --- a/supabase/migrations/.archived/042_product_import_rpc.sql +++ /dev/null @@ -1,130 +0,0 @@ --- Migration 042: Product Import RPC + upsert_product --- Idempotent: CREATE OR REPLACE FUNCTION - --- ── 1. upsert_product ───────────────────────────────────────────────────────── --- Upserts a single product by name (within brand). Returns the product id. --- Used by the CSV import tool to create or update products in bulk. - -CREATE OR REPLACE FUNCTION public.upsert_product( - p_brand_id UUID, - p_name TEXT, - p_price NUMERIC, - p_type TEXT, - p_description TEXT DEFAULT '', - p_active BOOLEAN DEFAULT true, - p_image_url TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_product_id UUID; - v_is_update BOOLEAN := false; -BEGIN - -- Check if product with same name exists in this brand - SELECT id INTO v_product_id - FROM products - WHERE brand_id = p_brand_id AND name = p_name - LIMIT 1; - - IF v_product_id IS NOT NULL THEN - -- Update existing - UPDATE products SET - name = p_name, - description = p_description, - price = p_price, - type = p_type, - active = p_active, - image_url = COALESCE(p_image_url, image_url), - updated_at = now() - WHERE id = v_product_id - RETURNING id INTO v_product_id; - v_is_update := true; - ELSE - -- Insert new - INSERT INTO products (brand_id, name, description, price, type, active, image_url) - VALUES (p_brand_id, p_name, p_description, p_price, p_type, p_active, p_image_url) - RETURNING id INTO v_product_id; - END IF; - - RETURN jsonb_build_object( - 'id', v_product_id, - 'is_update', v_is_update, - 'name', p_name - ); -END; -$$; - --- ── 2. bulk_upsert_products ────────────────────────────────────────────────── --- Takes an array of product records and upserts them all within a brand. --- Returns a summary: { created, updated, errors }. - -CREATE OR REPLACE FUNCTION public.bulk_upsert_products( - p_brand_id UUID, - p_products JSONB -- array of { name, description, price, type, active, image_url } -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_entry JSONB; - v_result JSONB := '{"created": 0, "updated": 0, "errors": []}'::JSONB; - v_name TEXT; - v_desc TEXT; - v_price NUMERIC; - v_type TEXT; - v_active BOOLEAN; - v_img TEXT; - v_was_new BOOLEAN; -BEGIN - FOR v_entry IN SELECT * FROM jsonb_array_elements(p_products) - LOOP - BEGIN - v_name := nullif(v_entry->>'name', ''); - v_desc := coalesce(nullif(v_entry->>'description', ''), ''); - v_price := nullif((v_entry->>'price')::TEXT, '')::NUMERIC; - v_type := nullif(v_entry->>'type', ''); - v_active := coalesce((v_entry->>'active')::BOOLEAN, true); - v_img := nullif(v_entry->>'image_url', ''); - - IF v_name IS NULL OR v_name = '' THEN - RAISE EXCEPTION 'Product name is required'; - END IF; - - IF v_price IS NULL OR v_price < 0 THEN - RAISE EXCEPTION 'Invalid price for product: %', v_name; - END IF; - - IF v_type NOT IN ('Pickup', 'Shipping', 'Pickup & Shipping') THEN - RAISE EXCEPTION 'Invalid type for product: %. Must be Pickup, Shipping, or Pickup & Shipping', v_name; - END IF; - - v_was_new := NOT EXISTS ( - SELECT 1 FROM products WHERE brand_id = p_brand_id AND name = v_name - ); - - PERFORM upsert_product(p_brand_id, v_name, v_desc, v_price, v_type, v_active, v_img); - - IF v_was_new THEN - v_result := jsonb_set(v_result, '{created}', - to_jsonb((v_result->>'created')::INTEGER + 1)); - ELSE - v_result := jsonb_set(v_result, '{updated}', - to_jsonb((v_result->>'updated')::INTEGER + 1)); - END IF; - - EXCEPTION WHEN OTHERS THEN - v_result := jsonb_set( - v_result, '{errors}', - v_result->'errors' || jsonb_build_array( - jsonb_build_object('product', v_name, 'error', SQLERRM) - ) - ); - END; - END LOOP; - - RETURN v_result; -END; -$$; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/043_square_sync_settings.sql b/supabase/migrations/.archived/043_square_sync_settings.sql deleted file mode 100644 index b075d2a..0000000 --- a/supabase/migrations/.archived/043_square_sync_settings.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Migration 043: Square Sync Settings --- Adds Square Sync configuration columns to payment_settings - -ALTER TABLE payment_settings ADD COLUMN IF NOT EXISTS square_sync_enabled BOOLEAN NOT NULL DEFAULT false; -ALTER TABLE payment_settings ADD COLUMN IF NOT EXISTS square_inventory_mode TEXT NOT NULL DEFAULT 'none'; --- Values: 'none' | 'rc_to_square' | 'square_to_rc' | 'bidirectional' -ALTER TABLE payment_settings ADD COLUMN IF NOT EXISTS square_last_sync_at TIMESTAMPTZ; -ALTER TABLE payment_settings ADD COLUMN IF NOT EXISTS square_last_sync_error TEXT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/044_square_sync_log.sql b/supabase/migrations/.archived/044_square_sync_log.sql deleted file mode 100644 index a8203b5..0000000 --- a/supabase/migrations/.archived/044_square_sync_log.sql +++ /dev/null @@ -1,63 +0,0 @@ --- Migration 044: Square Sync Log --- Append-only log of all Square sync events - -CREATE TABLE IF NOT EXISTS public.square_sync_log ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - event_type TEXT NOT NULL, - -- 'oauth_connected' | 'oauth_disconnected' | 'product_synced' | 'order_synced' | 'inventory_synced' | 'error' - direction TEXT, - -- 'rc_to_square' | 'square_to_rc' - entity_type TEXT, - -- 'product' | 'order' | 'inventory' - entity_id UUID, - status TEXT NOT NULL, - -- 'success' | 'error' | 'partial' - message TEXT, - details JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_square_sync_log_brand_created - ON square_sync_log(brand_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS idx_square_sync_log_event_type - ON square_sync_log(event_type, created_at DESC); - --- SECURITY DEFINER function to record sync log entries --- Bypasses RLS since operational events use the same pattern -CREATE OR REPLACE FUNCTION public.record_square_sync_event( - p_brand_id UUID, - p_event_type TEXT, - p_direction TEXT, - p_entity_type TEXT, - p_entity_id UUID, - p_status TEXT, - p_message TEXT, - p_details JSONB DEFAULT '{}'::JSONB -) -RETURNS void -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO square_sync_log ( - brand_id, event_type, direction, entity_type, entity_id, status, message, details - ) VALUES ( - p_brand_id, p_event_type, p_direction, p_entity_type, p_entity_id, p_status, p_message, p_details - ); -END; -$$; - --- RLS: brands can read their own sync logs; platform_admin can read all -ALTER TABLE square_sync_log ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "brand_admin_read_own_sync_log" ON square_sync_log - FOR SELECT USING ( - (current_setting('app.settings.role', true)::TEXT = 'brand_admin' AND brand_id = current_setting('app.settings.brand_id', true)::UUID) - OR current_setting('app.settings.role', true)::TEXT = 'platform_admin' - ); - -CREATE POLICY "service_insert_sync_log" ON square_sync_log - FOR INSERT WITH CHECK (true); -- service role bypasses RLS via SECURITY DEFINER - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/045_extend_payment_settings_rpcs.sql b/supabase/migrations/.archived/045_extend_payment_settings_rpcs.sql deleted file mode 100644 index 4f22ce5..0000000 --- a/supabase/migrations/.archived/045_extend_payment_settings_rpcs.sql +++ /dev/null @@ -1,53 +0,0 @@ --- Migration 045: Extend payment_settings RPCs for Square Sync --- Adds square_sync_enabled, square_inventory_mode, square_last_sync_at, square_last_sync_error --- to upsert_payment_settings and get_payment_settings - -CREATE OR REPLACE FUNCTION public.upsert_payment_settings( - p_brand_id UUID, - p_provider TEXT, - p_stripe_publishable_key TEXT DEFAULT NULL, - p_stripe_secret_key TEXT DEFAULT NULL, - p_square_access_token TEXT DEFAULT NULL, - p_square_location_id TEXT DEFAULT NULL, - p_square_sync_enabled BOOLEAN DEFAULT NULL, - p_square_inventory_mode TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO payment_settings ( - brand_id, provider, - stripe_publishable_key, stripe_secret_key, - square_access_token, square_location_id, - square_sync_enabled, square_inventory_mode - ) - VALUES ( - p_brand_id, p_provider, - p_stripe_publishable_key, p_stripe_secret_key, - p_square_access_token, p_square_location_id, - COALESCE(p_square_sync_enabled, false), - COALESCE(p_square_inventory_mode, 'none') - ) - ON CONFLICT (brand_id) DO UPDATE SET - provider = COALESCE(p_provider, payment_settings.provider), - stripe_publishable_key = COALESCE(p_stripe_publishable_key, payment_settings.stripe_publishable_key), - stripe_secret_key = COALESCE(p_stripe_secret_key, payment_settings.stripe_secret_key), - square_access_token = COALESCE(p_square_access_token, payment_settings.square_access_token), - square_location_id = COALESCE(p_square_location_id, payment_settings.square_location_id), - square_sync_enabled = COALESCE(p_square_sync_enabled, payment_settings.square_sync_enabled), - square_inventory_mode = COALESCE(p_square_inventory_mode, payment_settings.square_inventory_mode), - updated_at = now() - RETURNING jsonb_build_object( - 'id', id, - 'brand_id', brand_id, - 'provider', provider, - 'square_sync_enabled', square_sync_enabled, - 'square_inventory_mode', square_inventory_mode, - 'square_last_sync_at', square_last_sync_at, - 'square_last_sync_error', square_last_sync_error - ); -END; -$$; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/046_wholesale_portal.sql b/supabase/migrations/.archived/046_wholesale_portal.sql deleted file mode 100644 index 582cee6..0000000 --- a/supabase/migrations/.archived/046_wholesale_portal.sql +++ /dev/null @@ -1,748 +0,0 @@ --- Migration 046: Wholesale Portal Tables --- Core wholesale ordering portal for Route Commerce. --- Idempotent — all statements use IF NOT EXISTS / CREATE OR REPLACE. - -BEGIN; - --- ── 1. Wholesale Customers ───────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.wholesale_customers ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID, -- FK added after auth.users exists - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - company_name TEXT, - contact_name TEXT, - email TEXT, - phone TEXT, - billing_address TEXT, - shipping_address TEXT, - account_status TEXT NOT NULL DEFAULT 'active', - credit_limit NUMERIC(10,2) NOT NULL DEFAULT 0, - deposits_enabled BOOLEAN NOT NULL DEFAULT false, - deposit_threshold NUMERIC(10,2), - deposit_percentage INTEGER CHECK (deposit_percentage IS NULL OR deposit_percentage BETWEEN 1 AND 100), - order_email TEXT, - invoice_email TEXT, - admin_notes TEXT, - role TEXT NOT NULL DEFAULT 'buyer', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Add the FK after the table exists. Use DO block so it's idempotent. -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_user_id_fkey' - ) THEN - ALTER TABLE public.wholesale_customers - ADD CONSTRAINT wholesale_customers_user_id_fkey - FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL - NOT VALID; - END IF; -END $$; - --- Individual unique constraint on user_id (allows FK reference from other tables) -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_user_id_unique' - ) THEN - ALTER TABLE public.wholesale_customers - ADD CONSTRAINT wholesale_customers_user_id_unique - UNIQUE (user_id); - END IF; -END $$; - --- Composite unique: one user + one brand (still useful for the upsert ON CONFLICT) -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_brand_user_unique' - ) THEN - ALTER TABLE public.wholesale_customers - ADD CONSTRAINT wholesale_customers_brand_user_unique - UNIQUE (brand_id, user_id); - END IF; -END $$; - --- RLS -ALTER TABLE public.wholesale_customers ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "brand_admin_manage_wholesale_customers" ON wholesale_customers; -CREATE POLICY "brand_admin_manage_wholesale_customers" ON wholesale_customers - FOR ALL USING ( - current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin') - AND ( - current_setting('app.settings.role', true)::TEXT = 'platform_admin' - OR brand_id = current_setting('app.settings.brand_id', true)::UUID - ) - ); - -DROP POLICY IF EXISTS "wholesale_customer_read_own" ON wholesale_customers; -CREATE POLICY "wholesale_customer_read_own" ON wholesale_customers - FOR SELECT USING ( - current_setting('app.settings.role', true)::TEXT = 'wholesale_customer' - AND user_id = current_setting('app.settings.user_id', true)::UUID - ); - --- ── 2. Wholesale Products ─────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.wholesale_products ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - rc_product_id UUID REFERENCES products(id) ON DELETE SET NULL, - name TEXT NOT NULL, - description TEXT, - unit_type TEXT NOT NULL DEFAULT 'each', - unit_type_custom TEXT, - availability TEXT NOT NULL DEFAULT 'unavailable', - qty_available NUMERIC(10,2) DEFAULT 0, - season_start DATE, - season_end DATE, - price_tiers JSONB NOT NULL DEFAULT '[]', - hp_sku TEXT, - hp_item_id TEXT, - internal_notes TEXT, - handling_instructions TEXT, - transport_temp TEXT, - storage_warning TEXT, - loading_notes TEXT, - product_label TEXT, - pack_style TEXT, - container_type TEXT, - container_size_code TEXT, - units_per_container INTEGER, - container_notes TEXT, - default_pickup_location TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -ALTER TABLE public.wholesale_products ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "brand_admin_manage_wholesale_products" ON wholesale_products; -CREATE POLICY "brand_admin_manage_wholesale_products" ON wholesale_products - FOR ALL USING ( - current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin') - AND ( - current_setting('app.settings.role', true)::TEXT = 'platform_admin' - OR brand_id = current_setting('app.settings.brand_id', true)::UUID - ) - ); - --- ── 3. Wholesale Orders ──────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.wholesale_orders ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - customer_id UUID NOT NULL, -- FK added below after customers table exists - wc_order_id BIGINT, - status TEXT NOT NULL DEFAULT 'pending', - fulfillment_status TEXT NOT NULL DEFAULT 'unfulfilled', - payment_status TEXT NOT NULL DEFAULT 'unpaid', - anticipated_pickup_date DATE, - subtotal NUMERIC(10,2) NOT NULL DEFAULT 0, - deposit_required NUMERIC(10,2) DEFAULT 0, - deposit_paid NUMERIC(10,2) NOT NULL DEFAULT 0, - balance_due NUMERIC(10,2) NOT NULL DEFAULT 0, - assigned_employee_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, - fulfillment_notes TEXT, - internal_notes TEXT, - invoice_number TEXT, - invoice_pdf_path TEXT, - invoice_token TEXT, - invoice_type TEXT, - deposit_percentage INTEGER, - fulfilled_at TIMESTAMPTZ, - fulfilled_by UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Add customer FK now that customers table definitely exists -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_orders_customer_id_fkey' - ) THEN - ALTER TABLE public.wholesale_orders - ADD CONSTRAINT wholesale_orders_customer_id_fkey - FOREIGN KEY (customer_id) REFERENCES wholesale_customers(id) ON DELETE RESTRICT - NOT VALID; - END IF; -END $$; - -ALTER TABLE public.wholesale_orders ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "brand_admin_manage_wholesale_orders" ON wholesale_orders; -CREATE POLICY "brand_admin_manage_wholesale_orders" ON wholesale_orders - FOR ALL USING ( - current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin') - AND ( - current_setting('app.settings.role', true)::TEXT = 'platform_admin' - OR brand_id = current_setting('app.settings.brand_id', true)::UUID - ) - ); - --- ── 4. Wholesale Order Items ─────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.wholesale_order_items ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - wholesale_order_id UUID NOT NULL, -- FK added below - product_id UUID NOT NULL, -- FK added below - quantity NUMERIC(10,2) NOT NULL, - unit_price NUMERIC(10,2) NOT NULL, - line_total NUMERIC(10,2) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_order_items_order_id_fkey' - ) THEN - ALTER TABLE public.wholesale_order_items - ADD CONSTRAINT wholesale_order_items_order_id_fkey - FOREIGN KEY (wholesale_order_id) REFERENCES wholesale_orders(id) ON DELETE CASCADE - NOT VALID; - END IF; -END $$; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_order_items_product_id_fkey' - ) THEN - ALTER TABLE public.wholesale_order_items - ADD CONSTRAINT wholesale_order_items_product_id_fkey - FOREIGN KEY (product_id) REFERENCES wholesale_products(id) ON DELETE RESTRICT - NOT VALID; - END IF; -END $$; - -ALTER TABLE public.wholesale_order_items ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "brand_admin_manage_wholesale_order_items" ON wholesale_order_items; -CREATE POLICY "brand_admin_manage_wholesale_order_items" ON wholesale_order_items - FOR ALL USING ( - current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin') - ); - --- ── 5. Deposit Transactions ───────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.wholesale_deposits ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - wholesale_order_id UUID NOT NULL, -- FK added below - amount NUMERIC(10,2) NOT NULL, - payment_method TEXT, - reference TEXT, - recorded_by UUID NOT NULL REFERENCES auth.users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_deposits_order_id_fkey' - ) THEN - ALTER TABLE public.wholesale_deposits - ADD CONSTRAINT wholesale_deposits_order_id_fkey - FOREIGN KEY (wholesale_order_id) REFERENCES wholesale_orders(id) ON DELETE CASCADE - NOT VALID; - END IF; -END $$; - -ALTER TABLE public.wholesale_deposits ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "brand_admin_manage_deposits" ON wholesale_deposits; -CREATE POLICY "brand_admin_manage_deposits" ON wholesale_deposits - FOR ALL USING ( - current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin') - ); - --- ── 6. Wholesale Settings ─────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.wholesale_settings ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, - portal_page_id UUID, - price_sheet_page_id UUID, - require_approval BOOLEAN NOT NULL DEFAULT true, - min_order_amount NUMERIC(10,2), - online_payment_enabled BOOLEAN NOT NULL DEFAULT false, - pickup_location TEXT, - fob_location TEXT, - from_email TEXT, - invoice_business_name TEXT, - last_invoice_number INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -ALTER TABLE public.wholesale_settings ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "brand_admin_manage_wholesale_settings" ON wholesale_settings; -CREATE POLICY "brand_admin_manage_wholesale_settings" ON wholesale_settings - FOR ALL USING ( - current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin') - AND ( - current_setting('app.settings.role', true)::TEXT = 'platform_admin' - OR brand_id = current_setting('app.settings.brand_id', true)::UUID - ) - ); - -DROP FUNCTION IF EXISTS public.get_wholesale_orders(UUID); -DROP FUNCTION IF EXISTS public.upsert_wholesale_settings(UUID,UUID,UUID,BOOLEAN,NUMERIC,BOOLEAN,TEXT,TEXT,TEXT,TEXT); -DROP FUNCTION IF EXISTS public.get_wholesale_settings(UUID); -DROP FUNCTION IF EXISTS public.get_wholesale_customers(UUID); -DROP FUNCTION IF EXISTS public.upsert_wholesale_customer(UUID,UUID,TEXT,TEXT,TEXT,TEXT,TEXT,NUMERIC,BOOLEAN,NUMERIC,INTEGER,TEXT,TEXT,TEXT,TEXT); -DROP FUNCTION IF EXISTS public.get_wholesale_products(UUID); -DROP FUNCTION IF EXISTS public.upsert_wholesale_product(UUID,UUID,TEXT,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID); -DROP FUNCTION IF EXISTS public.create_wholesale_order(UUID,UUID,DATE,JSONB,INTEGER,TEXT); -DROP FUNCTION IF EXISTS public.record_wholesale_deposit(UUID,NUMERIC,TEXT,TEXT,UUID); -DROP FUNCTION IF EXISTS public.mark_wholesale_order_fulfilled(UUID,UUID); - --- ── 7. RPCs ───────────────────────────────────────────────────────────────── - -CREATE OR REPLACE FUNCTION public.get_wholesale_orders(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result - FROM ( - SELECT - wo.id, wo.status, wo.fulfillment_status, wo.payment_status, - wo.anticipated_pickup_date, wo.subtotal, wo.deposit_required, - wo.deposit_paid, wo.balance_due, wo.created_at, wo.updated_at, - wo.invoice_number, wo.assigned_employee_id, - wc.company_name, wc.contact_name, wc.email AS customer_email, - COALESCE(jsonb_agg(CASE WHEN woi.id IS NOT NULL THEN jsonb_build_object( - 'id', woi.id, - 'product_name', wp.name, - 'quantity', woi.quantity, - 'unit_price', woi.unit_price, - 'line_total', woi.line_total - ) END), '[]'::JSONB) AS items, - wo.fulfilled_at - FROM wholesale_orders wo - JOIN wholesale_customers wc ON wo.customer_id = wc.id - LEFT JOIN wholesale_order_items woi ON woi.wholesale_order_id = wo.id - LEFT JOIN wholesale_products wp ON woi.product_id = wp.id - WHERE wo.brand_id = p_brand_id - GROUP BY wo.id, wc.id - ORDER BY wo.created_at DESC - LIMIT 500 - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - -CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings( - p_brand_id UUID DEFAULT NULL, - p_portal_page_id UUID DEFAULT NULL, - p_price_sheet_page_id UUID DEFAULT NULL, - p_require_approval BOOLEAN DEFAULT NULL, - p_min_order_amount NUMERIC DEFAULT NULL, - p_online_payment_enabled BOOLEAN DEFAULT NULL, - p_pickup_location TEXT DEFAULT NULL, - p_fob_location TEXT DEFAULT NULL, - p_from_email TEXT DEFAULT NULL, - p_invoice_business_name TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO wholesale_settings ( - brand_id, portal_page_id, price_sheet_page_id, - require_approval, min_order_amount, online_payment_enabled, - pickup_location, fob_location, from_email, invoice_business_name - ) - VALUES ( - p_brand_id, p_portal_page_id, p_price_sheet_page_id, - COALESCE(p_require_approval, true), - p_min_order_amount, COALESCE(p_online_payment_enabled, false), - p_pickup_location, p_fob_location, p_from_email, p_invoice_business_name - ) - ON CONFLICT (brand_id) DO UPDATE SET - portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id), - price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id), - require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval), - min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount), - online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled), - pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location), - fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location), - from_email = COALESCE(p_from_email, wholesale_settings.from_email), - invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name), - updated_at = now() - RETURNING jsonb_build_object('success', true); -END; -$$; - -CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_build_object( - 'id', id, - 'brand_id', brand_id, - 'portal_page_id', portal_page_id, - 'price_sheet_page_id', price_sheet_page_id, - 'require_approval', require_approval, - 'min_order_amount', min_order_amount, - 'online_payment_enabled', online_payment_enabled, - 'pickup_location', pickup_location, - 'fob_location', fob_location, - 'from_email', from_email, - 'invoice_business_name', invoice_business_name, - 'last_invoice_number', last_invoice_number - ) INTO v_result - FROM wholesale_settings - WHERE brand_id = p_brand_id; - RETURN v_result; -END; -$$; - -CREATE OR REPLACE FUNCTION public.get_wholesale_customers(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.company_name NULLS LAST) INTO v_result - FROM ( - SELECT - id, user_id, company_name, contact_name, email, phone, - account_status, credit_limit, - deposits_enabled, deposit_threshold, deposit_percentage, - order_email, invoice_email, admin_notes, role, - created_at - FROM wholesale_customers - WHERE brand_id = p_brand_id - ORDER BY company_name NULLS LAST - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - -CREATE OR REPLACE FUNCTION public.upsert_wholesale_customer( - p_brand_id UUID DEFAULT NULL, - p_user_id UUID DEFAULT NULL, - p_company_name TEXT DEFAULT NULL, - p_contact_name TEXT DEFAULT NULL, - p_email TEXT DEFAULT NULL, - p_phone TEXT DEFAULT NULL, - p_account_status TEXT DEFAULT NULL, - p_credit_limit NUMERIC DEFAULT NULL, - p_deposits_enabled BOOLEAN DEFAULT NULL, - p_deposit_threshold NUMERIC DEFAULT NULL, - p_deposit_percentage INTEGER DEFAULT NULL, - p_order_email TEXT DEFAULT NULL, - p_invoice_email TEXT DEFAULT NULL, - p_admin_notes TEXT DEFAULT NULL, - p_role TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_id UUID; -BEGIN - INSERT INTO wholesale_customers ( - brand_id, user_id, company_name, contact_name, email, phone, - account_status, credit_limit, deposits_enabled, deposit_threshold, - deposit_percentage, order_email, invoice_email, admin_notes, role - ) - VALUES ( - p_brand_id, p_user_id, p_company_name, p_contact_name, p_email, p_phone, - COALESCE(p_account_status, 'active'), - COALESCE(p_credit_limit, 0), - COALESCE(p_deposits_enabled, false), - p_deposit_threshold, p_deposit_percentage, - p_order_email, p_invoice_email, p_admin_notes, - COALESCE(p_role, 'buyer') - ) - ON CONFLICT (brand_id, user_id) DO UPDATE SET - company_name = COALESCE(p_company_name, wholesale_customers.company_name), - contact_name = COALESCE(p_contact_name, wholesale_customers.contact_name), - email = COALESCE(p_email, wholesale_customers.email), - phone = COALESCE(p_phone, wholesale_customers.phone), - account_status = COALESCE(p_account_status, wholesale_customers.account_status), - credit_limit = COALESCE(p_credit_limit, wholesale_customers.credit_limit), - deposits_enabled = COALESCE(p_deposits_enabled, wholesale_customers.deposits_enabled), - deposit_threshold = COALESCE(p_deposit_threshold, wholesale_customers.deposit_threshold), - deposit_percentage= COALESCE(p_deposit_percentage, wholesale_customers.deposit_percentage), - order_email = COALESCE(p_order_email, wholesale_customers.order_email), - invoice_email = COALESCE(p_invoice_email, wholesale_customers.invoice_email), - admin_notes = COALESCE(p_admin_notes, wholesale_customers.admin_notes), - role = COALESCE(p_role, wholesale_customers.role), - updated_at = now() - RETURNING id INTO v_id; - - RETURN jsonb_build_object('success', true, 'id', v_id); -END; -$$; - -CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.name) INTO v_result - FROM ( - SELECT - id, name, description, unit_type, unit_type_custom, - availability, qty_available, season_start, season_end, - price_tiers, hp_sku, hp_item_id, - handling_instructions, storage_warning, loading_notes, - product_label, pack_style, container_type, container_size_code, - units_per_container, default_pickup_location, - created_at - FROM wholesale_products - WHERE brand_id = p_brand_id - ORDER BY name - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - -CREATE OR REPLACE FUNCTION public.upsert_wholesale_product( - p_brand_id UUID DEFAULT NULL, - p_id UUID DEFAULT NULL, - p_name TEXT DEFAULT NULL, - p_description TEXT DEFAULT NULL, - p_unit_type TEXT DEFAULT NULL, - p_unit_type_custom TEXT DEFAULT NULL, - p_availability TEXT DEFAULT NULL, - p_qty_available NUMERIC DEFAULT NULL, - p_season_start DATE DEFAULT NULL, - p_season_end DATE DEFAULT NULL, - p_price_tiers JSONB DEFAULT NULL, - p_hp_sku TEXT DEFAULT NULL, - p_hp_item_id TEXT DEFAULT NULL, - p_internal_notes TEXT DEFAULT NULL, - p_handling_instructions TEXT DEFAULT NULL, - p_storage_warning TEXT DEFAULT NULL, - p_loading_notes TEXT DEFAULT NULL, - p_product_label TEXT DEFAULT NULL, - p_pack_style TEXT DEFAULT NULL, - p_container_type TEXT DEFAULT NULL, - p_container_size_code TEXT DEFAULT NULL, - p_units_per_container INTEGER DEFAULT NULL, - p_default_pickup_location TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_id UUID; -BEGIN - IF p_id IS NOT NULL THEN - UPDATE wholesale_products SET - name = COALESCE(p_name, name), - description = COALESCE(p_description, description), - unit_type = COALESCE(p_unit_type, unit_type), - unit_type_custom = COALESCE(p_unit_type_custom, unit_type_custom), - availability = COALESCE(p_availability, availability), - qty_available = COALESCE(p_qty_available, qty_available), - season_start = COALESCE(p_season_start, season_start), - season_end = COALESCE(p_season_end, season_end), - price_tiers = COALESCE(p_price_tiers, price_tiers), - hp_sku = COALESCE(p_hp_sku, hp_sku), - hp_item_id = COALESCE(p_hp_item_id, hp_item_id), - internal_notes = COALESCE(p_internal_notes, internal_notes), - handling_instructions = COALESCE(p_handling_instructions, handling_instructions), - storage_warning = COALESCE(p_storage_warning, storage_warning), - loading_notes = COALESCE(p_loading_notes, loading_notes), - product_label = COALESCE(p_product_label, product_label), - pack_style = COALESCE(p_pack_style, pack_style), - container_type = COALESCE(p_container_type, container_type), - container_size_code = COALESCE(p_container_size_code, container_size_code), - units_per_container = COALESCE(p_units_per_container, units_per_container), - default_pickup_location = COALESCE(p_default_pickup_location, default_pickup_location), - updated_at = now() - WHERE id = p_id AND brand_id = p_brand_id - RETURNING id INTO v_id; - ELSE - INSERT INTO wholesale_products ( - brand_id, name, description, unit_type, unit_type_custom, - availability, qty_available, season_start, season_end, - price_tiers, hp_sku, hp_item_id, internal_notes, - handling_instructions, storage_warning, loading_notes, - product_label, pack_style, container_type, container_size_code, - units_per_container, default_pickup_location - ) VALUES ( - p_brand_id, p_name, p_description, - COALESCE(p_unit_type, 'each'), - p_unit_type_custom, - COALESCE(p_availability, 'unavailable'), - COALESCE(p_qty_available, 0), - p_season_start, p_season_end, - COALESCE(p_price_tiers, '[]'::JSONB), - p_hp_sku, p_hp_item_id, p_internal_notes, - p_handling_instructions, p_storage_warning, p_loading_notes, - p_product_label, p_pack_style, p_container_type, p_container_size_code, - p_units_per_container, p_default_pickup_location - ) - RETURNING id INTO v_id; - END IF; - - RETURN jsonb_build_object('success', true, 'id', v_id); -END; -$$; - -CREATE OR REPLACE FUNCTION public.create_wholesale_order( - p_brand_id UUID DEFAULT NULL, - p_customer_id UUID DEFAULT NULL, - p_anticipated_pickup_date DATE DEFAULT NULL, - p_items JSONB DEFAULT '[]'::JSONB, - p_deposit_percentage INTEGER DEFAULT NULL, - p_notes TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order_id UUID; - v_subtotal NUMERIC(10,2); - v_dep_required NUMERIC(10,2) := 0; - v_customer RECORD; - v_settings RECORD; - v_next_inv INTEGER; - v_status TEXT; -BEGIN - -- Resolve customer deposit rules - SELECT deposits_enabled, deposit_threshold, deposit_percentage - INTO v_customer - FROM wholesale_customers - WHERE id = p_customer_id; - - -- Resolve invoice counter - SELECT COALESCE(last_invoice_number, 0) INTO v_settings - FROM wholesale_settings - WHERE brand_id = p_brand_id; - - -- Calculate subtotal - v_subtotal := COALESCE( - (SELECT SUM( - ((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC) - ) FROM jsonb_array_elements(p_items) AS item), - 0 - ); - - -- Determine if deposit required - IF v_customer.deposits_enabled THEN - IF v_customer.deposit_threshold IS NULL OR v_subtotal >= v_customer.deposit_threshold THEN - v_dep_required := ROUND(v_subtotal * COALESCE(v_customer.deposit_percentage, 0) / 100.0, 2); - END IF; - END IF; - - v_status := CASE WHEN v_dep_required > 0 THEN 'awaiting_deposit' ELSE 'pending' END; - v_next_inv := COALESCE(v_settings.last_invoice_number, 0) + 1; - - INSERT INTO wholesale_orders ( - brand_id, customer_id, status, anticipated_pickup_date, - subtotal, deposit_required, deposit_paid, balance_due, - deposit_percentage, internal_notes, invoice_number - ) VALUES ( - p_brand_id, p_customer_id, v_status, p_anticipated_pickup_date, - v_subtotal, v_dep_required, 0, v_subtotal, - p_deposit_percentage, p_notes, - 'INV-' || LPAD(v_next_inv::TEXT, 5, '0') - ) RETURNING id INTO v_order_id; - - -- Insert line items - IF jsonb_array_length(p_items) > 0 THEN - INSERT INTO wholesale_order_items (wholesale_order_id, product_id, quantity, unit_price, line_total) - SELECT - v_order_id, - (item->>'product_id')::UUID, - (item->>'quantity')::NUMERIC, - (item->>'unit_price')::NUMERIC, - ((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC) - FROM jsonb_array_elements(p_items) AS item; - END IF; - - -- Advance invoice counter - UPDATE wholesale_settings - SET last_invoice_number = v_next_inv, updated_at = now() - WHERE brand_id = p_brand_id; - - RETURN jsonb_build_object( - 'success', true, - 'order_id', v_order_id, - 'status', v_status, - 'deposit_required', v_dep_required, - 'subtotal', v_subtotal - ); -END; -$$; - -CREATE OR REPLACE FUNCTION public.record_wholesale_deposit( - p_order_id UUID DEFAULT NULL, - p_amount NUMERIC DEFAULT NULL, - p_method TEXT DEFAULT 'cash', - p_reference TEXT DEFAULT NULL, - p_recorded_by UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_new_paid NUMERIC(10,2); -BEGIN - -- Compute new deposit_paid first so balance_due uses the correct updated value - SELECT deposit_paid + p_amount INTO v_new_paid - FROM wholesale_orders - WHERE id = p_order_id; - - UPDATE wholesale_orders - SET - deposit_paid = v_new_paid, - balance_due = subtotal - v_new_paid, - updated_at = now() - WHERE id = p_order_id; - - INSERT INTO wholesale_deposits (wholesale_order_id, amount, payment_method, reference, recorded_by) - VALUES (p_order_id, p_amount, p_method, p_reference, p_recorded_by); - - -- Advance to pending once deposit covers requirement - UPDATE wholesale_orders - SET status = 'pending' - WHERE id = p_order_id - AND v_new_paid >= deposit_required - AND status = 'awaiting_deposit'; - - RETURN jsonb_build_object('success', true); -END; -$$; - -CREATE OR REPLACE FUNCTION public.mark_wholesale_order_fulfilled( - p_order_id UUID DEFAULT NULL, - p_by UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE wholesale_orders - SET - fulfillment_status = 'fulfilled', - payment_status = CASE WHEN balance_due <= 0 THEN 'paid' ELSE payment_status END, - status = 'fulfilled', - fulfilled_at = now(), - fulfilled_by = p_by, - updated_at = now() - WHERE id = p_order_id; - - RETURN jsonb_build_object('success', true); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/047_wholesale_registration.sql b/supabase/migrations/.archived/047_wholesale_registration.sql deleted file mode 100644 index 4995e2a..0000000 --- a/supabase/migrations/.archived/047_wholesale_registration.sql +++ /dev/null @@ -1,201 +0,0 @@ --- Migration 047: Wholesale Registration + Pending Approval Status --- Adds self-service registration RPC and 'pending_approval' account status. --- Idempotent — uses CREATE OR REPLACE and IF NOT EXISTS. - -BEGIN; - --- Add pending_approval to the account_status check constraint -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_account_status_check' - ) THEN - ALTER TABLE public.wholesale_customers - ADD CONSTRAINT wholesale_customers_account_status_check - CHECK (account_status IN ('active', 'on_hold', 'disabled', 'pending_approval', 'rejected')); - ELSE - -- Drop and recreate the check constraint with the new value - ALTER TABLE public.wholesale_customers - DROP CONSTRAINT wholesale_customers_account_status_check; - ALTER TABLE public.wholesale_customers - ADD CONSTRAINT wholesale_customers_account_status_check - CHECK (account_status IN ('active', 'on_hold', 'disabled', 'pending_approval', 'rejected')); - END IF; -END $$; - --- Add rejected status to the check constraint (same pattern) -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_account_status_check' - ) THEN - ALTER TABLE public.wholesale_customers - ADD CONSTRAINT wholesale_customers_account_status_check - CHECK (account_status IN ('active', 'on_hold', 'disabled', 'pending_approval', 'rejected')); - END IF; -END $$; - --- ── Registration RPC ───────────────────────────────────────────────────────── - -DROP FUNCTION IF EXISTS public.register_wholesale_customer(UUID, TEXT, TEXT, TEXT, TEXT); -CREATE OR REPLACE FUNCTION public.register_wholesale_customer( - p_brand_id UUID DEFAULT NULL, - p_company_name TEXT DEFAULT NULL, - p_contact_name TEXT DEFAULT NULL, - p_email TEXT DEFAULT NULL, - p_phone TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_id UUID; - v_req_app BOOLEAN := true; -BEGIN - -- Reject duplicate email for this brand - IF EXISTS (SELECT 1 FROM wholesale_customers WHERE brand_id = p_brand_id AND email = p_email) THEN - RETURN jsonb_build_object('success', false, 'error', 'An account with this email already exists.'); - END IF; - - -- Read require_approval setting for this brand - BEGIN - SELECT require_approval INTO v_req_app - FROM wholesale_settings - WHERE brand_id = p_brand_id; - EXCEPTION WHEN OTHERS THEN - v_req_app := true; - END; - - INSERT INTO wholesale_customers ( - brand_id, company_name, contact_name, email, phone, - account_status, role - ) VALUES ( - p_brand_id, - p_company_name, - p_contact_name, - p_email, - p_phone, - CASE WHEN COALESCE(v_req_app, true) THEN 'pending_approval' ELSE 'active' END, - 'buyer' - ) - RETURNING id INTO v_id; - - RETURN jsonb_build_object( - 'success', true, - 'id', v_id, - 'requires_approval', COALESCE(v_req_app, true) - ); -END; -$$; - --- ── Customer lookup by user_id (for portal auth) ────────────────────────────── - -DROP FUNCTION IF EXISTS public.get_wholesale_customer_by_user(UUID, UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_customer_by_user( - p_brand_id UUID DEFAULT NULL, - p_user_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - -- If brand_id is a zero UUID (placeholder), search across all brands - IF p_brand_id = '00000000-0000-0000-0000-000000000000'::UUID THEN - RETURN ( - SELECT jsonb_build_object( - 'id', id, - 'user_id', user_id, - 'company_name', company_name, - 'contact_name', contact_name, - 'email', email, - 'phone', phone, - 'account_status', account_status, - 'role', role, - 'brand_id', brand_id - ) - FROM wholesale_customers - WHERE user_id = p_user_id AND account_status = 'active' - LIMIT 1 - ); - END IF; - - RETURN ( - SELECT jsonb_build_object( - 'id', id, - 'user_id', user_id, - 'company_name', company_name, - 'contact_name', contact_name, - 'email', email, - 'phone', phone, - 'account_status', account_status, - 'role', role, - 'brand_id', brand_id - ) - FROM wholesale_customers - WHERE brand_id = p_brand_id AND user_id = p_user_id - ); -END; -$$; - --- ── Pending registrations RPC (for admin approval queue) ────────────────────── - -DROP FUNCTION IF EXISTS public.get_pending_wholesale_registrations(UUID); -CREATE OR REPLACE FUNCTION public.get_pending_wholesale_registrations(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.created_at ASC) INTO v_result - FROM ( - SELECT - id, company_name, contact_name, email, phone, - account_status, role, created_at, updated_at - FROM wholesale_customers - WHERE brand_id = p_brand_id - AND account_status IN ('pending_approval', 'rejected') - ORDER BY created_at ASC - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - --- ── Approve/reject registration RPC ─────────────────────────────────────────── - -DROP FUNCTION IF EXISTS public.approve_wholesale_registration(UUID, UUID, TEXT); -CREATE OR REPLACE FUNCTION public.approve_wholesale_registration( - p_registration_id UUID DEFAULT NULL, - p_brand_id UUID DEFAULT NULL, - p_action TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - IF p_action = 'approve' THEN - UPDATE wholesale_customers - SET account_status = 'active', updated_at = now() - WHERE id = p_registration_id AND brand_id = p_brand_id - AND account_status = 'pending_approval'; - ELSIF p_action = 'reject' THEN - UPDATE wholesale_customers - SET account_status = 'rejected', updated_at = now() - WHERE id = p_registration_id AND brand_id = p_brand_id - AND account_status = 'pending_approval'; - ELSE - RETURN jsonb_build_object('success', false, 'error', 'Invalid action.'); - END IF; - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── Upsert wholesale customer (updated to allow linking user_id) ────────────── - --- Update existing upsert to handle user_id linking --- The existing upsert_wholesale_customer is already set up to use brand_id+user_id as the ON CONFLICT target. --- This just adds a note that it can also be used to link an existing customer to a auth.users account. - -COMMIT; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/048_wholesale_enabled.sql b/supabase/migrations/.archived/048_wholesale_enabled.sql deleted file mode 100644 index 7bfa73c..0000000 --- a/supabase/migrations/.archived/048_wholesale_enabled.sql +++ /dev/null @@ -1,62 +0,0 @@ --- Migration 048: Add wholesale_enabled to wholesale_settings + update upsert RPC --- Allows admins to enable/disable the Wholesale Portal per brand. - -BEGIN; - --- Add wholesale_enabled column -ALTER TABLE public.wholesale_settings - ADD COLUMN IF NOT EXISTS wholesale_enabled BOOLEAN NOT NULL DEFAULT true; - --- Update upsert function to handle wholesale_enabled -DROP FUNCTION IF EXISTS public.upsert_wholesale_settings( - UUID, UUID, UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN, TEXT, TEXT, TEXT, TEXT -); - -CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings( - p_brand_id UUID DEFAULT NULL, - p_portal_page_id UUID DEFAULT NULL, - p_price_sheet_page_id UUID DEFAULT NULL, - p_require_approval BOOLEAN DEFAULT NULL, - p_min_order_amount NUMERIC DEFAULT NULL, - p_online_payment_enabled BOOLEAN DEFAULT NULL, - p_wholesale_enabled BOOLEAN DEFAULT NULL, - p_pickup_location TEXT DEFAULT NULL, - p_fob_location TEXT DEFAULT NULL, - p_from_email TEXT DEFAULT NULL, - p_invoice_business_name TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO wholesale_settings ( - brand_id, portal_page_id, price_sheet_page_id, - require_approval, min_order_amount, online_payment_enabled, - wholesale_enabled, pickup_location, fob_location, from_email, invoice_business_name - ) - VALUES ( - p_brand_id, p_portal_page_id, p_price_sheet_page_id, - COALESCE(p_require_approval, true), - p_min_order_amount, COALESCE(p_online_payment_enabled, false), - COALESCE(p_wholesale_enabled, true), - p_pickup_location, p_fob_location, p_from_email, p_invoice_business_name - ) - ON CONFLICT (brand_id) DO UPDATE SET - portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id), - price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id), - require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval), - min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount), - online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled), - wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled), - pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location), - fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location), - from_email = COALESCE(p_from_email, wholesale_settings.from_email), - invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name), - updated_at = now() - RETURNING jsonb_build_object('success', true); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/049_wholesale_invoice_settings.sql b/supabase/migrations/.archived/049_wholesale_invoice_settings.sql deleted file mode 100644 index 31183a6..0000000 --- a/supabase/migrations/.archived/049_wholesale_invoice_settings.sql +++ /dev/null @@ -1,211 +0,0 @@ --- Migration 049: Invoice Settings + TC Invoice Numbers + Secure Token --- - Adds invoice business contact fields to wholesale_settings --- - Updates create_wholesale_order to generate TC-XXXXXX format + secure token --- - Updates upsert_wholesale_settings to persist the new invoice fields - -BEGIN; - --- ── 1. Add invoice business contact columns to wholesale_settings ──────────── -ALTER TABLE public.wholesale_settings - ADD COLUMN IF NOT EXISTS invoice_business_address TEXT, - ADD COLUMN IF NOT EXISTS invoice_business_phone TEXT, - ADD COLUMN IF NOT EXISTS invoice_business_email TEXT, - ADD COLUMN IF NOT EXISTS invoice_business_website TEXT; - --- ── 2. Update upsert_wholesale_settings RPC to handle new invoice fields ───── -DROP FUNCTION IF EXISTS public.upsert_wholesale_settings( - UUID, UUID, UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT -); - -CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings( - p_brand_id UUID DEFAULT NULL, - p_portal_page_id UUID DEFAULT NULL, - p_price_sheet_page_id UUID DEFAULT NULL, - p_require_approval BOOLEAN DEFAULT NULL, - p_min_order_amount NUMERIC DEFAULT NULL, - p_online_payment_enabled BOOLEAN DEFAULT NULL, - p_wholesale_enabled BOOLEAN DEFAULT NULL, - p_pickup_location TEXT DEFAULT NULL, - p_fob_location TEXT DEFAULT NULL, - p_from_email TEXT DEFAULT NULL, - p_invoice_business_name TEXT DEFAULT NULL, - p_invoice_business_address TEXT DEFAULT NULL, - p_invoice_business_phone TEXT DEFAULT NULL, - p_invoice_business_email TEXT DEFAULT NULL, - p_invoice_business_website TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO wholesale_settings ( - brand_id, portal_page_id, price_sheet_page_id, - require_approval, min_order_amount, online_payment_enabled, - wholesale_enabled, pickup_location, fob_location, from_email, - invoice_business_name, invoice_business_address, - invoice_business_phone, invoice_business_email, invoice_business_website - ) - VALUES ( - p_brand_id, p_portal_page_id, p_price_sheet_page_id, - COALESCE(p_require_approval, true), - p_min_order_amount, COALESCE(p_online_payment_enabled, false), - COALESCE(p_wholesale_enabled, true), - p_pickup_location, p_fob_location, p_from_email, - p_invoice_business_name, p_invoice_business_address, - p_invoice_business_phone, p_invoice_business_email, p_invoice_business_website - ) - ON CONFLICT (brand_id) DO UPDATE SET - portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id), - price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id), - require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval), - min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount), - online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled), - wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled), - pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location), - fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location), - from_email = COALESCE(p_from_email, wholesale_settings.from_email), - invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name), - invoice_business_address = COALESCE(p_invoice_business_address, wholesale_settings.invoice_business_address), - invoice_business_phone = COALESCE(p_invoice_business_phone, wholesale_settings.invoice_business_phone), - invoice_business_email = COALESCE(p_invoice_business_email, wholesale_settings.invoice_business_email), - invoice_business_website = COALESCE(p_invoice_business_website, wholesale_settings.invoice_business_website), - updated_at = now(); - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── 3. Update create_wholesale_order to generate TC-XXXXXX + invoice_token ─── -DROP FUNCTION IF EXISTS public.create_wholesale_order( - UUID, UUID, DATE, JSONB, INTEGER, TEXT -); - -CREATE OR REPLACE FUNCTION public.create_wholesale_order( - p_brand_id UUID DEFAULT NULL, - p_customer_id UUID DEFAULT NULL, - p_anticipated_pickup_date DATE DEFAULT NULL, - p_items JSONB DEFAULT '[]'::JSONB, - p_deposit_percentage INTEGER DEFAULT NULL, - p_notes TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order_id UUID; - v_subtotal NUMERIC(10,2); - v_dep_required NUMERIC(10,2) := 0; - v_customer RECORD; - v_settings RECORD; - v_inv_number TEXT; - v_inv_token TEXT; - v_status TEXT; -BEGIN - -- Resolve customer deposit rules - SELECT deposits_enabled, deposit_threshold, deposit_percentage - INTO v_customer - FROM wholesale_customers - WHERE id = p_customer_id; - - -- Generate TC-XXXXXX invoice number using RANDOM() - v_inv_number := 'TC-' || LPAD(FLOOR(RANDOM() * 1000000)::INT::TEXT, 6, '0'); - -- Generate a 32-char hex invoice token using md5 of random + timestamp - v_inv_token := md5(random()::text || clock_timestamp()::text || now()::text || p_brand_id::text); - - -- Calculate subtotal - v_subtotal := COALESCE( - (SELECT SUM( - ((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC) - ) FROM jsonb_array_elements(p_items) AS item), - 0 - ); - - -- Determine if deposit required - IF v_customer.deposits_enabled THEN - IF v_customer.deposit_threshold IS NULL OR v_subtotal >= v_customer.deposit_threshold THEN - v_dep_required := ROUND(v_subtotal * COALESCE(v_customer.deposit_percentage, 0) / 100.0, 2); - END IF; - END IF; - - v_status := CASE WHEN v_dep_required > 0 THEN 'awaiting_deposit' ELSE 'pending' END; - - INSERT INTO wholesale_orders ( - brand_id, customer_id, status, anticipated_pickup_date, - subtotal, deposit_required, deposit_paid, balance_due, - deposit_percentage, internal_notes, invoice_number, invoice_token - ) VALUES ( - p_brand_id, p_customer_id, v_status, p_anticipated_pickup_date, - v_subtotal, v_dep_required, 0, v_subtotal, - p_deposit_percentage, p_notes, - v_inv_number, v_inv_token - ) RETURNING id INTO v_order_id; - - -- Insert line items - IF jsonb_array_length(p_items) > 0 THEN - INSERT INTO wholesale_order_items (wholesale_order_id, product_id, quantity, unit_price, line_total) - SELECT - v_order_id, - (item->>'product_id')::UUID, - (item->>'quantity')::NUMERIC, - (item->>'unit_price')::NUMERIC, - ((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC) - FROM jsonb_array_elements(p_items) AS item; - END IF; - - RETURN jsonb_build_object( - 'success', true, - 'order_id', v_order_id, - 'invoice_number', v_inv_number, - 'status', v_status, - 'deposit_required', v_dep_required, - 'subtotal', v_subtotal - ); -END; -$$; - --- ── 4. RPC: Get wholesale orders for a customer (customer-facing order history) ── -DROP FUNCTION IF EXISTS public.get_wholesale_customer_orders(UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_customer_orders(p_customer_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result - FROM ( - SELECT - wo.id, - wo.status, - wo.fulfillment_status, - wo.payment_status, - wo.anticipated_pickup_date, - wo.subtotal, - wo.deposit_required, - wo.deposit_paid, - wo.balance_due, - wo.invoice_number, - wo.invoice_token, - wo.created_at, - wo.updated_at, - COALESCE(jsonb_agg(CASE WHEN woi.id IS NOT NULL THEN jsonb_build_object( - 'id', woi.id, - 'product_name', wp.name, - 'quantity', woi.quantity, - 'unit_price', woi.unit_price, - 'line_total', woi.line_total - ) END), '[]'::JSONB) AS items - FROM wholesale_orders wo - LEFT JOIN wholesale_order_items woi ON woi.wholesale_order_id = wo.id - LEFT JOIN wholesale_products wp ON woi.product_id = wp.id - WHERE wo.customer_id = p_customer_id - GROUP BY wo.id - ORDER BY wo.created_at DESC - LIMIT 100 - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/050_wholesale_online_payment.sql b/supabase/migrations/.archived/050_wholesale_online_payment.sql deleted file mode 100644 index 5c08c8f..0000000 --- a/supabase/migrations/.archived/050_wholesale_online_payment.sql +++ /dev/null @@ -1,78 +0,0 @@ --- Migration 050: Wholesale Online Payment --- - Adds checkout_session_id and payment_intent_id to wholesale_orders --- - Adds record_wholesale_payment RPC for Stripe webhook to call - -BEGIN; - --- ── 1. Add Stripe payment tracking columns ─────────────────────────────────── -ALTER TABLE public.wholesale_orders - ADD COLUMN IF NOT EXISTS checkout_session_id TEXT, - ADD COLUMN IF NOT EXISTS stripe_payment_intent_id TEXT; - --- ── 2. RPC: Record a Stripe payment against a wholesale order ───────────────── -DROP FUNCTION IF EXISTS public.record_wholesale_payment(UUID, NUMERIC, TEXT); -CREATE OR REPLACE FUNCTION public.record_wholesale_payment( - p_order_id UUID DEFAULT NULL, - p_amount NUMERIC DEFAULT NULL, - p_stripe_payment_intent_id TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order RECORD; - v_new_paid NUMERIC(10,2); -BEGIN - -- Fetch current order state - SELECT id, subtotal, deposit_paid, deposit_required, balance_due - INTO v_order - FROM wholesale_orders - WHERE id = p_order_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Order not found'); - END IF; - - -- Compute new deposit_paid before the update so balance_due uses the correct value - v_new_paid := v_order.deposit_paid + p_amount; - - -- Record the payment in the deposits table - INSERT INTO wholesale_deposits (wholesale_order_id, amount, payment_method, reference, recorded_by) - VALUES ( - p_order_id, - p_amount, - 'stripe', - p_stripe_payment_intent_id, - auth.uid() - ); - - -- Update order - UPDATE wholesale_orders - SET - deposit_paid = v_new_paid, - balance_due = GREATEST(subtotal - v_new_paid, 0), - payment_status = CASE - WHEN subtotal - v_new_paid <= 0 THEN 'paid' - ELSE 'partial' - END, - stripe_payment_intent_id = COALESCE(p_stripe_payment_intent_id, stripe_payment_intent_id), - updated_at = now() - WHERE id = p_order_id; - - -- Advance status from awaiting_deposit if deposit now covers requirement - UPDATE wholesale_orders - SET status = 'pending' - WHERE id = p_order_id - AND status = 'awaiting_deposit' - AND v_new_paid >= deposit_required; - - RETURN jsonb_build_object( - 'success', true, - 'new_balance_due', GREATEST(v_order.subtotal - v_new_paid, 0) - ); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/051_wholesale_customer_pricing.sql b/supabase/migrations/.archived/051_wholesale_customer_pricing.sql deleted file mode 100644 index 660f523..0000000 --- a/supabase/migrations/.archived/051_wholesale_customer_pricing.sql +++ /dev/null @@ -1,92 +0,0 @@ --- Migration 051: Customer-Specific Pricing Overrides --- Allows admin to set custom per-customer per-product prices --- that override the standard product price tiers. - -BEGIN; - --- ── 1. Wholesale Customer Product Pricing table ──────────────────────────────── -CREATE TABLE IF NOT EXISTS public.wholesale_customer_product_pricing ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE, - product_id UUID NOT NULL REFERENCES wholesale_products(id) ON DELETE CASCADE, - custom_unit_price NUMERIC(10,2) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (customer_id, product_id) -); - -ALTER TABLE public.wholesale_customer_product_pricing ENABLE ROW LEVEL SECURITY; - --- Admins can manage overrides -DROP POLICY IF EXISTS "brand_admin_manage_pricing_overrides" ON wholesale_customer_product_pricing; -CREATE POLICY "brand_admin_manage_pricing_overrides" ON wholesale_customer_product_pricing - FOR ALL USING ( - current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin') - ); - --- ── 2. RPC: Get all pricing overrides for a customer ───────────────────────── -DROP FUNCTION IF EXISTS public.get_wholesale_customer_pricing(UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_customer_pricing(p_customer_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(jsonb_build_object( - 'product_id', wcpp.product_id, - 'custom_unit_price', wcpp.custom_unit_price, - 'product_name', wp.name - )) INTO v_result - FROM wholesale_customer_product_pricing wcpp - JOIN wholesale_products wp ON wp.id = wcpp.product_id - WHERE wcpp.customer_id = p_customer_id; - - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - --- ── 3. RPC: Upsert a single pricing override ───────────────────────────────── -DROP FUNCTION IF EXISTS public.upsert_wholesale_customer_pricing(UUID, UUID, NUMERIC); -CREATE OR REPLACE FUNCTION public.upsert_wholesale_customer_pricing( - p_customer_id UUID DEFAULT NULL, - p_product_id UUID DEFAULT NULL, - p_custom_unit_price NUMERIC DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_id UUID; -BEGIN - INSERT INTO wholesale_customer_product_pricing (customer_id, product_id, custom_unit_price) - VALUES (p_customer_id, p_product_id, p_custom_unit_price) - ON CONFLICT (customer_id, product_id) DO UPDATE SET - custom_unit_price = p_custom_unit_price, - updated_at = now() - RETURNING id INTO v_id; - - RETURN jsonb_build_object('success', true, 'id', v_id); -END; -$$; - --- ── 4. RPC: Delete a pricing override ───────────────────────────────────────── -DROP FUNCTION IF EXISTS public.delete_wholesale_customer_pricing(UUID, UUID); -CREATE OR REPLACE FUNCTION public.delete_wholesale_customer_pricing( - p_customer_id UUID DEFAULT NULL, - p_product_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - DELETE FROM wholesale_customer_product_pricing - WHERE customer_id = p_customer_id AND product_id = p_product_id; - - RETURN jsonb_build_object('success', true); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/052_wholesale_credit_limit.sql b/supabase/migrations/.archived/052_wholesale_credit_limit.sql deleted file mode 100644 index 79726a3..0000000 --- a/supabase/migrations/.archived/052_wholesale_credit_limit.sql +++ /dev/null @@ -1,124 +0,0 @@ --- Migration 052: Credit Limit Enforcement --- Blocks wholesale orders that would exceed the customer's credit limit. - -BEGIN; - --- ── Update create_wholesale_order to enforce credit limits ─────────────────────── --- Adds: credit_limit check, outstanding balance calculation, rejection if exceeded -DROP FUNCTION IF EXISTS public.create_wholesale_order( - UUID, UUID, DATE, JSONB, INTEGER, TEXT -); - -CREATE OR REPLACE FUNCTION public.create_wholesale_order( - p_brand_id UUID DEFAULT NULL, - p_customer_id UUID DEFAULT NULL, - p_anticipated_pickup_date DATE DEFAULT NULL, - p_items JSONB DEFAULT '[]'::JSONB, - p_deposit_percentage INTEGER DEFAULT NULL, - p_notes TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order_id UUID; - v_subtotal NUMERIC(10,2); - v_dep_required NUMERIC(10,2) := 0; - v_customer RECORD; - v_settings RECORD; - v_inv_number TEXT; - v_inv_token TEXT; - v_status TEXT; - v_outstanding NUMERIC(10,2); -BEGIN - -- Resolve customer: credit limit + deposit rules - SELECT - credit_limit, - deposits_enabled, - deposit_threshold, - deposit_percentage - INTO v_customer - FROM wholesale_customers - WHERE id = p_customer_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Customer not found'); - END IF; - - -- Calculate subtotal - v_subtotal := COALESCE( - (SELECT SUM( - ((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC) - ) FROM jsonb_array_elements(p_items) AS item), - 0 - ); - - -- Credit limit enforcement: reject if order would exceed limit - IF v_customer.credit_limit > 0 THEN - SELECT COALESCE(SUM(balance_due), 0) INTO v_outstanding - FROM wholesale_orders - WHERE customer_id = p_customer_id - AND status != 'fulfilled'; - - IF v_outstanding + v_subtotal > v_customer.credit_limit THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Order exceeds available credit. Please reduce the order size or pay outstanding balance.', - 'credit_limit', v_customer.credit_limit, - 'outstanding_balance', v_outstanding, - 'order_total', v_subtotal - ); - END IF; - END IF; - - -- Generate TC-XXXXXX invoice number using RANDOM() - v_inv_number := 'TC-' || LPAD(FLOOR(RANDOM() * 1000000)::INT::TEXT, 6, '0'); - -- Generate a 32-char hex invoice token using md5 of random + timestamp - v_inv_token := md5(random()::text || clock_timestamp()::text || now()::text || p_brand_id::text); - - -- Determine if deposit required - IF v_customer.deposits_enabled THEN - IF v_customer.deposit_threshold IS NULL OR v_subtotal >= v_customer.deposit_threshold THEN - v_dep_required := ROUND(v_subtotal * COALESCE(v_customer.deposit_percentage, 0) / 100.0, 2); - END IF; - END IF; - - v_status := CASE WHEN v_dep_required > 0 THEN 'awaiting_deposit' ELSE 'pending' END; - - INSERT INTO wholesale_orders ( - brand_id, customer_id, status, anticipated_pickup_date, - subtotal, deposit_required, deposit_paid, balance_due, - deposit_percentage, internal_notes, invoice_number, invoice_token - ) VALUES ( - p_brand_id, p_customer_id, v_status, p_anticipated_pickup_date, - v_subtotal, v_dep_required, 0, v_subtotal, - p_deposit_percentage, p_notes, - v_inv_number, v_inv_token - ) RETURNING id INTO v_order_id; - - -- Insert line items - IF jsonb_array_length(p_items) > 0 THEN - INSERT INTO wholesale_order_items (wholesale_order_id, product_id, quantity, unit_price, line_total) - SELECT - v_order_id, - (item->>'product_id')::UUID, - (item->>'quantity')::NUMERIC, - (item->>'unit_price')::NUMERIC, - ((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC) - FROM jsonb_array_elements(p_items) AS item; - END IF; - - RETURN jsonb_build_object( - 'success', true, - 'order_id', v_order_id, - 'invoice_number', v_inv_number, - 'status', v_status, - 'deposit_required', v_dep_required, - 'subtotal', v_subtotal - ); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/053_wholesale_bulk_actions.sql b/supabase/migrations/.archived/053_wholesale_bulk_actions.sql deleted file mode 100644 index 1a82922..0000000 --- a/supabase/migrations/.archived/053_wholesale_bulk_actions.sql +++ /dev/null @@ -1,93 +0,0 @@ --- Migration 053: Wholesale Bulk Actions --- Bulk fulfill and bulk deposit record RPCs for admin order management. - -BEGIN; - --- ── Bulk fulfill: mark multiple orders as fulfilled in one call ───────────────── -DROP FUNCTION IF EXISTS public.bulk_fulfill_wholesale_orders(UUID[], UUID); -CREATE OR REPLACE FUNCTION public.bulk_fulfill_wholesale_orders( - p_order_ids UUID[] DEFAULT NULL, - p_by UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_count INTEGER := 0; -BEGIN - UPDATE wholesale_orders - SET - fulfillment_status = 'fulfilled', - payment_status = CASE WHEN balance_due <= 0 THEN 'paid' ELSE payment_status END, - status = 'fulfilled', - fulfilled_at = now(), - fulfilled_by = p_by, - updated_at = now() - WHERE id = ANY(p_order_ids) - AND fulfillment_status != 'fulfilled'; - - GET DIAGNOSTICS v_count = ROW_COUNT; - - RETURN jsonb_build_object('success', true, 'count', v_count); -END; -$$; - --- ── Bulk record deposit: record a deposit on multiple orders ───────────────────── -DROP FUNCTION IF EXISTS public.bulk_record_wholesale_deposit(UUID[], NUMERIC, TEXT, TEXT, UUID); -CREATE OR REPLACE FUNCTION public.bulk_record_wholesale_deposit( - p_order_ids UUID[] DEFAULT NULL, - p_amount NUMERIC DEFAULT NULL, - p_method TEXT DEFAULT 'cash', - p_reference TEXT DEFAULT NULL, - p_recorded_by UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order RECORD; - v_new_paid NUMERIC(10,2); - v_count INTEGER := 0; -BEGIN - FOR v_order IN - SELECT id, subtotal, deposit_paid, deposit_required, balance_due - FROM wholesale_orders - WHERE id = ANY(p_order_ids) - AND status != 'fulfilled' - LOOP - -- Compute new deposit_paid before the update so balance_due uses the correct value - v_new_paid := v_order.deposit_paid + p_amount; - - -- Record deposit - INSERT INTO wholesale_deposits (wholesale_order_id, amount, payment_method, reference, recorded_by) - VALUES (v_order.id, p_amount, p_method, p_reference, p_recorded_by); - - -- Update order - UPDATE wholesale_orders - SET - deposit_paid = v_new_paid, - balance_due = GREATEST(subtotal - v_new_paid, 0), - payment_status = CASE - WHEN subtotal - v_new_paid <= 0 THEN 'paid' - ELSE payment_status - END, - updated_at = now() - WHERE id = v_order.id; - - -- Advance from awaiting_deposit if deposit now covers requirement - UPDATE wholesale_orders - SET status = 'pending' - WHERE id = v_order.id - AND status = 'awaiting_deposit' - AND v_new_paid >= deposit_required; - - v_count := v_count + 1; - END LOOP; - - RETURN jsonb_build_object('success', true, 'count', v_count); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/054_wholesale_email_notifications.sql b/supabase/migrations/.archived/054_wholesale_email_notifications.sql deleted file mode 100644 index 0eba899..0000000 --- a/supabase/migrations/.archived/054_wholesale_email_notifications.sql +++ /dev/null @@ -1,160 +0,0 @@ --- Migration 054: Wholesale Email Notifications --- Notification queue: stores email intents to be processed by an external --- email service (Resend, SendGrid, etc.) or a cron webhook. - -BEGIN; - --- ── 1. Notification types enum ─────────────────────────────────────────────── -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'wholesale_notification_type') THEN - CREATE TYPE wholesale_notification_type AS ENUM ( - 'order_confirmation', - 'deposit_received', - 'order_fulfilled' - ); - END IF; -END $$; - --- ── 2. Notifications table ──────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.wholesale_notifications ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE, - order_id UUID REFERENCES wholesale_orders(id) ON DELETE SET NULL, - type wholesale_notification_type NOT NULL, - email_to TEXT NOT NULL, - email_cc TEXT, - subject TEXT NOT NULL, - body_html TEXT, - body_text TEXT, - status TEXT NOT NULL DEFAULT 'pending', - -- 'pending' | 'sent' | 'failed' | 'skipped' - sent_at TIMESTAMPTZ, - error_message TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -ALTER TABLE public.wholesale_notifications ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "brand_admin_manage_notifications" ON wholesale_notifications; -CREATE POLICY "brand_admin_manage_notifications" ON wholesale_notifications - FOR ALL USING ( - current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin') - AND ( - current_setting('app.settings.role', true)::TEXT = 'platform_admin' - OR brand_id = current_setting('app.settings.brand_id', true)::UUID - ) - ); - --- ── 3. RPC: Enqueue a wholesale notification ───────────────────────────────── -DROP FUNCTION IF EXISTS public.enqueue_wholesale_notification( - UUID, UUID, UUID, wholesale_notification_type, TEXT, TEXT, TEXT, TEXT, TEXT -); -CREATE OR REPLACE FUNCTION public.enqueue_wholesale_notification( - p_brand_id UUID DEFAULT NULL, - p_customer_id UUID DEFAULT NULL, - p_order_id UUID DEFAULT NULL, - p_type wholesale_notification_type DEFAULT NULL, - p_email_to TEXT DEFAULT NULL, - p_email_cc TEXT DEFAULT NULL, - p_subject TEXT DEFAULT NULL, - p_body_html TEXT DEFAULT NULL, - p_body_text TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_id UUID; -BEGIN - INSERT INTO wholesale_notifications ( - brand_id, customer_id, order_id, type, - email_to, email_cc, subject, body_html, body_text - ) - VALUES ( - p_brand_id, p_customer_id, p_order_id, p_type, - p_email_to, p_email_cc, p_subject, p_body_html, p_body_text - ) - RETURNING id INTO v_id; - - RETURN jsonb_build_object('success', true, 'id', v_id); -END; -$$; - --- ── 4. RPC: Get pending notifications (for external processor / cron) ─────────── -DROP FUNCTION IF EXISTS public.get_wholesale_pending_notifications(UUID, INTEGER); -CREATE OR REPLACE FUNCTION public.get_wholesale_pending_notifications( - p_brand_id UUID DEFAULT NULL, - p_limit INTEGER DEFAULT 50 -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.created_at ASC) INTO v_result - FROM ( - SELECT - wn.id, wn.type, wn.email_to, wn.email_cc, - wn.subject, wn.body_html, wn.body_text, - wn.brand_id, wn.customer_id, wn.order_id, - ws.invoice_business_name, ws.invoice_business_email - FROM wholesale_notifications wn - LEFT JOIN wholesale_settings ws ON ws.brand_id = wn.brand_id - WHERE wn.status = 'pending' - AND (p_brand_id IS NULL OR wn.brand_id = p_brand_id) - ORDER BY wn.created_at ASC - LIMIT p_limit - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - --- ── 5. RPC: Mark notification as sent ───────────────────────────────────────── -DROP FUNCTION IF EXISTS public.mark_wholesale_notification_sent(UUID, TEXT); -CREATE OR REPLACE FUNCTION public.mark_wholesale_notification_sent( - p_notification_id UUID DEFAULT NULL, - p_error TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE wholesale_notifications - SET - status = CASE WHEN p_error IS NULL THEN 'sent' ELSE 'failed' END, - error_message = p_error, - sent_at = CASE WHEN p_error IS NULL THEN now() ELSE sent_at END, - updated_at = now() - WHERE id = p_notification_id; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── 6. RPC: Get notification stats per brand ────────────────────────────────── -DROP FUNCTION IF EXISTS public.get_wholesale_notification_stats(UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_notification_stats(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_build_object( - 'pending', (SELECT COUNT(*) FROM wholesale_notifications WHERE brand_id = p_brand_id AND status = 'pending'), - 'sent', (SELECT COUNT(*) FROM wholesale_notifications WHERE brand_id = p_brand_id AND status = 'sent'), - 'failed', (SELECT COUNT(*) FROM wholesale_notifications WHERE brand_id = p_brand_id AND status = 'failed'), - 'total', (SELECT COUNT(*) FROM wholesale_notifications WHERE brand_id = p_brand_id) - ) INTO v_result; - - RETURN v_result; -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/055_wholesale_notification_settings.sql b/supabase/migrations/.archived/055_wholesale_notification_settings.sql deleted file mode 100644 index 67a7f8a..0000000 --- a/supabase/migrations/.archived/055_wholesale_notification_settings.sql +++ /dev/null @@ -1,154 +0,0 @@ --- Migration 055: Team Notification Email + Pickup Reminder Types --- - Adds notification_email field to wholesale_settings (team inbox per brand) --- - Adds pickup_reminder and unclaimed_pickup to wholesale_notification_type enum --- - Updates get_wholesale_settings to return notification_email - -BEGIN; - --- ── 1. Add notification_email column ───────────────────────────────────────── -ALTER TABLE public.wholesale_settings - ADD COLUMN IF NOT EXISTS notification_email TEXT; - --- ── 2. Extend notification type enum ──────────────────────────────────────────── -ALTER TYPE wholesale_notification_type ADD VALUE IF NOT EXISTS 'pickup_reminder'; -ALTER TYPE wholesale_notification_type ADD VALUE IF NOT EXISTS 'unclaimed_pickup'; - --- ── 3. Update get_wholesale_settings to return all settings fields ────────────── --- Recreate to include notification_email (new) and all invoice fields (from 049) -DROP FUNCTION IF EXISTS public.get_wholesale_settings(UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_build_object( - 'id', id, - 'brand_id', brand_id, - 'portal_page_id', portal_page_id, - 'price_sheet_page_id', price_sheet_page_id, - 'require_approval', require_approval, - 'min_order_amount', min_order_amount, - 'online_payment_enabled', online_payment_enabled, - 'wholesale_enabled', wholesale_enabled, - 'pickup_location', pickup_location, - 'fob_location', fob_location, - 'from_email', from_email, - 'invoice_business_name', invoice_business_name, - 'invoice_business_address', invoice_business_address, - 'invoice_business_phone', invoice_business_phone, - 'invoice_business_email', invoice_business_email, - 'invoice_business_website', invoice_business_website, - 'notification_email', notification_email, - 'last_invoice_number', last_invoice_number - ) INTO v_result - FROM wholesale_settings - WHERE brand_id = p_brand_id; - RETURN v_result; -END; -$$; - --- ── 4. Add get_wholesale_overdue_orders RPC ───────────────────────────────────── --- Returns fulfilled orders past their anticipated pickup date with customer + settings info -CREATE OR REPLACE FUNCTION public.get_wholesale_overdue_orders() -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t) INTO v_result - FROM ( - SELECT - wo.id, - wo.brand_id, - wo.customer_id, - wo.invoice_number, - wo.anticipated_pickup_date, - wc.email AS customer_email, - ws.pickup_location, - ws.notification_email, - ws.from_email, - ws.invoice_business_email - FROM wholesale_orders wo - JOIN wholesale_customers wc ON wo.customer_id = wc.id - LEFT JOIN wholesale_settings ws ON wo.brand_id = ws.brand_id - WHERE wo.status = 'fulfilled' - AND wo.fulfillment_status = 'fulfilled' - AND wo.anticipated_pickup_date < CURRENT_DATE - ORDER BY wo.anticipated_pickup_date ASC - LIMIT 100 - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - --- ── 5. Update upsert_wholesale_settings to accept notification_email ────────────── -DROP FUNCTION IF EXISTS public.upsert_wholesale_settings( - UUID, UUID, UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT -); -CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings( - p_brand_id UUID DEFAULT NULL, - p_portal_page_id UUID DEFAULT NULL, - p_price_sheet_page_id UUID DEFAULT NULL, - p_require_approval BOOLEAN DEFAULT NULL, - p_min_order_amount NUMERIC DEFAULT NULL, - p_online_payment_enabled BOOLEAN DEFAULT NULL, - p_wholesale_enabled BOOLEAN DEFAULT NULL, - p_pickup_location TEXT DEFAULT NULL, - p_fob_location TEXT DEFAULT NULL, - p_from_email TEXT DEFAULT NULL, - p_invoice_business_name TEXT DEFAULT NULL, - p_invoice_business_address TEXT DEFAULT NULL, - p_invoice_business_phone TEXT DEFAULT NULL, - p_invoice_business_email TEXT DEFAULT NULL, - p_invoice_business_website TEXT DEFAULT NULL, - p_notification_email TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO wholesale_settings ( - brand_id, portal_page_id, price_sheet_page_id, - require_approval, min_order_amount, online_payment_enabled, - wholesale_enabled, pickup_location, fob_location, from_email, - invoice_business_name, invoice_business_address, - invoice_business_phone, invoice_business_email, invoice_business_website, - notification_email - ) - VALUES ( - p_brand_id, p_portal_page_id, p_price_sheet_page_id, - COALESCE(p_require_approval, true), - p_min_order_amount, COALESCE(p_online_payment_enabled, false), - COALESCE(p_wholesale_enabled, true), - p_pickup_location, p_fob_location, p_from_email, - p_invoice_business_name, p_invoice_business_address, - p_invoice_business_phone, p_invoice_business_email, p_invoice_business_website, - p_notification_email - ) - ON CONFLICT (brand_id) DO UPDATE SET - portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id), - price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id), - require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval), - min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount), - online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled), - wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled), - pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location), - fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location), - from_email = COALESCE(p_from_email, wholesale_settings.from_email), - invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name), - invoice_business_address = COALESCE(p_invoice_business_address, wholesale_settings.invoice_business_address), - invoice_business_phone = COALESCE(p_invoice_business_phone, wholesale_settings.invoice_business_phone), - invoice_business_email = COALESCE(p_invoice_business_email, wholesale_settings.invoice_business_email), - invoice_business_website = COALESCE(p_invoice_business_website, wholesale_settings.invoice_business_website), - notification_email = COALESCE(p_notification_email, wholesale_settings.notification_email), - updated_at = now() - RETURNING jsonb_build_object('success', true); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/056_wholesale_price_sheet.sql b/supabase/migrations/.archived/056_wholesale_price_sheet.sql deleted file mode 100644 index 2991d9b..0000000 --- a/supabase/migrations/.archived/056_wholesale_price_sheet.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Migration 056: Wholesale Price Sheet Notification Type --- Adds price_sheet to the wholesale_notification_type enum. - -BEGIN; - -ALTER TYPE wholesale_notification_type ADD VALUE IF NOT EXISTS 'price_sheet'; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/057_notification_recipients.sql b/supabase/migrations/.archived/057_notification_recipients.sql deleted file mode 100644 index 9c11564..0000000 --- a/supabase/migrations/.archived/057_notification_recipients.sql +++ /dev/null @@ -1,123 +0,0 @@ --- Migration 057: Admin Notification Recipients --- Stores notification recipient list per brand on wholesale_settings. --- Safe to run multiple times; uses IF NOT EXISTS and COALESCE defaults. - -BEGIN; - --- ── 1. Add notification_recipients column ───────────────────────────────────── --- Uses IF NOT EXISTS so this is safe to run if column already exists. -ALTER TABLE public.wholesale_settings - ADD COLUMN IF NOT EXISTS notification_recipients JSONB NOT NULL - DEFAULT '[]'::JSONB; - --- ── 2. Update get_wholesale_settings ─────────────────────────────────────────── --- Always returns notification_recipients: [] instead of null (via COALESCE). -DROP FUNCTION IF EXISTS public.get_wholesale_settings(UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_build_object( - 'id', id, - 'brand_id', brand_id, - 'portal_page_id', portal_page_id, - 'price_sheet_page_id', price_sheet_page_id, - 'require_approval', require_approval, - 'min_order_amount', min_order_amount, - 'online_payment_enabled', online_payment_enabled, - 'wholesale_enabled', wholesale_enabled, - 'pickup_location', pickup_location, - 'fob_location', fob_location, - 'from_email', from_email, - 'invoice_business_name', invoice_business_name, - 'invoice_business_address', invoice_business_address, - 'invoice_business_phone', invoice_business_phone, - 'invoice_business_email', invoice_business_email, - 'invoice_business_website', invoice_business_website, - 'notification_email', notification_email, - 'notification_recipients', COALESCE(notification_recipients, '[]'::JSONB), - 'last_invoice_number', last_invoice_number - ) INTO v_result - FROM wholesale_settings - WHERE brand_id = p_brand_id; - RETURN v_result; -END; -$$; - --- ── 3. Update upsert_wholesale_settings ─────────────────────────────────────── --- Adds p_notification_recipients (JSONB). Defaults to [] if not passed. --- Safe to re-run: DROP IF EXISTS matches on exact signature. -DROP FUNCTION IF EXISTS public.upsert_wholesale_settings( - UUID, UUID, UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN, - TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, JSONB -); -CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings( - p_brand_id UUID DEFAULT NULL, - p_portal_page_id UUID DEFAULT NULL, - p_price_sheet_page_id UUID DEFAULT NULL, - p_require_approval BOOLEAN DEFAULT NULL, - p_min_order_amount NUMERIC DEFAULT NULL, - p_online_payment_enabled BOOLEAN DEFAULT NULL, - p_wholesale_enabled BOOLEAN DEFAULT NULL, - p_pickup_location TEXT DEFAULT NULL, - p_fob_location TEXT DEFAULT NULL, - p_from_email TEXT DEFAULT NULL, - p_invoice_business_name TEXT DEFAULT NULL, - p_invoice_business_address TEXT DEFAULT NULL, - p_invoice_business_phone TEXT DEFAULT NULL, - p_invoice_business_email TEXT DEFAULT NULL, - p_invoice_business_website TEXT DEFAULT NULL, - p_notification_email TEXT DEFAULT NULL, - p_notification_recipients JSONB DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO wholesale_settings ( - brand_id, portal_page_id, price_sheet_page_id, - require_approval, min_order_amount, online_payment_enabled, - wholesale_enabled, pickup_location, fob_location, from_email, - invoice_business_name, invoice_business_address, - invoice_business_phone, invoice_business_email, invoice_business_website, - notification_email, notification_recipients - ) - VALUES ( - p_brand_id, p_portal_page_id, p_price_sheet_page_id, - COALESCE(p_require_approval, true), - p_min_order_amount, COALESCE(p_online_payment_enabled, false), - COALESCE(p_wholesale_enabled, true), - p_pickup_location, p_fob_location, p_from_email, - p_invoice_business_name, p_invoice_business_address, - p_invoice_business_phone, p_invoice_business_email, p_invoice_business_website, - p_notification_email, - COALESCE(p_notification_recipients, '[]'::JSONB) - ) - ON CONFLICT (brand_id) DO UPDATE SET - portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id), - price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id), - require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval), - min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount), - online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled), - wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled), - pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location), - fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location), - from_email = COALESCE(p_from_email, wholesale_settings.from_email), - invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name), - invoice_business_address = COALESCE(p_invoice_business_address, wholesale_settings.invoice_business_address), - invoice_business_phone = COALESCE(p_invoice_business_phone, wholesale_settings.invoice_business_phone), - invoice_business_email = COALESCE(p_invoice_business_email, wholesale_settings.invoice_business_email), - invoice_business_website = COALESCE(p_invoice_business_website, wholesale_settings.invoice_business_website), - notification_email = COALESCE(p_notification_email, wholesale_settings.notification_email), - notification_recipients = COALESCE(p_notification_recipients, wholesale_settings.notification_recipients), - updated_at = now() - RETURNING jsonb_build_object('success', true); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/058_fix_get_wholesale_orders.sql b/supabase/migrations/.archived/058_fix_get_wholesale_orders.sql deleted file mode 100644 index 8c6eb52..0000000 --- a/supabase/migrations/.archived/058_fix_get_wholesale_orders.sql +++ /dev/null @@ -1,162 +0,0 @@ --- Migration 058: Fix get_wholesale_orders and get_wholesale_customer_orders --- Fixes: jsonb_build_object cannot be used inside jsonb_agg with FILTER. --- Replaced with correlated subquery approach that is valid in Supabase Postgres. - -BEGIN; - --- ── 1. get_wholesale_orders ──────────────────────────────────────────────────── -DROP FUNCTION IF EXISTS public.get_wholesale_orders(UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_orders(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result - FROM ( - SELECT - wo.id, - wo.status, - wo.fulfillment_status, - wo.payment_status, - wo.anticipated_pickup_date, - wo.subtotal, - wo.deposit_required, - wo.deposit_paid, - wo.balance_due, - wo.invoice_number, - wo.assigned_employee_id, - wo.created_at, - wo.updated_at, - wo.fulfilled_at, - wc.company_name, - wc.contact_name, - wc.email AS customer_email, - COALESCE( - ( - SELECT jsonb_agg( - CASE WHEN woi.id IS NOT NULL THEN - jsonb_build_object( - 'id', woi.id, - 'product_name', wp.name, - 'quantity', woi.quantity, - 'unit_price', woi.unit_price, - 'line_total', woi.line_total - ) - END - ) - FROM wholesale_order_items woi - LEFT JOIN wholesale_products wp ON woi.product_id = wp.id - WHERE woi.wholesale_order_id = wo.id - ), - '[]'::JSONB - ) AS items - FROM wholesale_orders wo - JOIN wholesale_customers wc ON wo.customer_id = wc.id - WHERE wo.brand_id = p_brand_id - ORDER BY wo.created_at DESC - LIMIT 500 - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - --- ── 2. get_wholesale_customer_orders ───────────────────────────────────────── -DROP FUNCTION IF EXISTS public.get_wholesale_customer_orders(UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_customer_orders(p_customer_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result - FROM ( - SELECT - wo.id, - wo.status, - wo.fulfillment_status, - wo.payment_status, - wo.anticipated_pickup_date, - wo.subtotal, - wo.deposit_required, - wo.deposit_paid, - wo.balance_due, - wo.invoice_number, - wo.invoice_token, - wo.created_at, - wo.updated_at, - COALESCE( - ( - SELECT jsonb_agg( - CASE WHEN woi.id IS NOT NULL THEN - jsonb_build_object( - 'id', woi.id, - 'product_name', wp.name, - 'quantity', woi.quantity, - 'unit_price', woi.unit_price, - 'line_total', woi.line_total - ) - END - ) - FROM wholesale_order_items woi - LEFT JOIN wholesale_products wp ON woi.product_id = wp.id - WHERE woi.wholesale_order_id = wo.id - ), - '[]'::JSONB - ) AS items - FROM wholesale_orders wo - WHERE wo.customer_id = p_customer_id - ORDER BY wo.created_at DESC - LIMIT 100 - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - --- ── 3. get_wholesale_products — simplified (no aggregate, no join issue) ─────── -DROP FUNCTION IF EXISTS public.get_wholesale_products(UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.name) INTO v_result - FROM ( - SELECT - wp.id, - wp.name, - wp.description, - wp.unit_type, - wp.unit_type_custom, - wp.availability, - wp.qty_available, - wp.season_start, - wp.season_end, - wp.price_tiers, - wp.hp_sku, - wp.hp_item_id, - wp.handling_instructions, - wp.storage_warning, - wp.loading_notes, - wp.product_label, - wp.pack_style, - wp.container_type, - wp.container_size_code, - wp.units_per_container, - wp.default_pickup_location, - wp.created_at - FROM wholesale_products wp - WHERE wp.brand_id = p_brand_id - ORDER BY wp.name - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/059_order_status_delete.sql b/supabase/migrations/.archived/059_order_status_delete.sql deleted file mode 100644 index e2126e9..0000000 --- a/supabase/migrations/.archived/059_order_status_delete.sql +++ /dev/null @@ -1,63 +0,0 @@ --- Migration 059: Order status update + delete RPCs --- Needed by the redesigned Actions column dropdown in WholesaleClient.tsx - -BEGIN; - --- ── update_wholesale_order_status ─────────────────────────────────────────── -DROP FUNCTION IF EXISTS public.update_wholesale_order_status(UUID, TEXT); -CREATE OR REPLACE FUNCTION public.update_wholesale_order_status( - p_order_id UUID, - p_status TEXT DEFAULT 'pending' -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order RECORD; -BEGIN - SELECT id, status INTO v_order FROM wholesale_orders WHERE id = p_order_id FOR UPDATE; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Order not found'); - END IF; - - IF v_order.status = 'fulfilled' THEN - RETURN jsonb_build_object('success', false, 'error', 'Cannot change status of a fulfilled order'); - END IF; - - UPDATE wholesale_orders - SET status = p_status, updated_at = now() - WHERE id = p_order_id; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── delete_wholesale_order ─────────────────────────────────────────────────── -DROP FUNCTION IF EXISTS public.delete_wholesale_order(UUID); -CREATE OR REPLACE FUNCTION public.delete_wholesale_order(p_order_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order RECORD; -BEGIN - SELECT id, status INTO v_order FROM wholesale_orders WHERE id = p_order_id FOR UPDATE; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Order not found'); - END IF; - - IF v_order.status = 'fulfilled' THEN - RETURN jsonb_build_object('success', false, 'error', 'Cannot delete a fulfilled order'); - END IF; - - DELETE FROM wholesale_order_items WHERE wholesale_order_id = p_order_id; - DELETE FROM wholesale_notifications WHERE order_id = p_order_id; - DELETE FROM wholesale_orders WHERE id = p_order_id; - - RETURN jsonb_build_object('success', true); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/060_wholesale_pickup_orders.sql b/supabase/migrations/.archived/060_wholesale_pickup_orders.sql deleted file mode 100644 index a634239..0000000 --- a/supabase/migrations/.archived/060_wholesale_pickup_orders.sql +++ /dev/null @@ -1,68 +0,0 @@ --- Migration 060: Wholesale Pickup Orders RPC for Employee Portal --- Returns unfulfilled wholesale orders partitioned by pickup date queue: --- past_due: anticipated_pickup_date < today --- today: anticipated_pickup_date = today --- upcoming: anticipated_pickup_date > today --- Uses correlated subquery pattern (same fix as migration 058). - -BEGIN; - -DROP FUNCTION IF EXISTS public.get_wholesale_pickup_orders(UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_pickup_orders(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.anticipated_pickup_date ASC) INTO v_result - FROM ( - SELECT - wo.id, - wo.status, - wo.fulfillment_status, - wo.payment_status, - wo.anticipated_pickup_date, - wo.subtotal, - wo.deposit_required, - wo.deposit_paid, - wo.balance_due, - wo.invoice_number, - wo.created_at, - wo.updated_at, - wo.fulfilled_at, - wc.company_name, - wc.contact_name, - wc.email AS customer_email, - wc.phone AS customer_phone, - COALESCE( - (SELECT jsonb_agg( - CASE WHEN woi.id IS NOT NULL THEN - jsonb_build_object( - 'id', woi.id, - 'product_name', wp.name, - 'quantity', woi.quantity, - 'unit_price', woi.unit_price, - 'line_total', woi.line_total - ) - END - ) - FROM wholesale_order_items woi - LEFT JOIN wholesale_products wp ON woi.product_id = wp.id - WHERE woi.wholesale_order_id = wo.id), - '[]'::JSONB - ) AS items - FROM wholesale_orders wo - JOIN wholesale_customers wc ON wo.customer_id = wc.id - WHERE wo.brand_id = p_brand_id - AND wo.fulfillment_status != 'fulfilled' - ORDER BY wo.anticipated_pickup_date ASC - LIMIT 500 - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/061_soft_delete.sql b/supabase/migrations/.archived/061_soft_delete.sql deleted file mode 100644 index 5ae0e18..0000000 --- a/supabase/migrations/.archived/061_soft_delete.sql +++ /dev/null @@ -1,167 +0,0 @@ --- Migration 061: Soft-delete for customers and products, order delete guard --- Soft delete via deleted_at TIMESTAMPTZ (never hard-deleted to preserve FK integrity) --- Updated get_wholesale_* to filter deleted_at IS NULL --- Updated delete_wholesale_order to block paid orders - -BEGIN; - --- ── 1. Add deleted_at columns ───────────────────────────────────────────────── -ALTER TABLE public.wholesale_customers ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; -ALTER TABLE public.wholesale_products ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; - --- ── 2. Update get_wholesale_customers to filter soft-deleted ──────────────── -DROP FUNCTION IF EXISTS public.get_wholesale_customers(UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_customers(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result - FROM ( - SELECT - id, user_id, company_name, contact_name, email, phone, - billing_address, shipping_address, account_status, - credit_limit, deposits_enabled, deposit_threshold, deposit_percentage, - order_email, invoice_email, admin_notes, role, created_at, updated_at, - deleted_at - FROM wholesale_customers - WHERE brand_id = p_brand_id - AND deleted_at IS NULL -- ← exclude soft-deleted - ORDER BY created_at DESC - LIMIT 500 - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - --- ── 3. Update get_wholesale_products to filter soft-deleted ───────────────── -DROP FUNCTION IF EXISTS public.get_wholesale_products(UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.name) INTO v_result - FROM ( - SELECT - id, brand_id, rc_product_id, name, description, - unit_type, unit_type_custom, availability, qty_available, - season_start, season_end, price_tiers, hp_sku, hp_item_id, - handling_instructions, storage_warning, loading_notes, - product_label, pack_style, container_type, container_size_code, - units_per_container, default_pickup_location, created_at, updated_at, - deleted_at - FROM wholesale_products - WHERE brand_id = p_brand_id - AND deleted_at IS NULL -- ← exclude soft-deleted - ORDER BY name - LIMIT 500 - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - --- ── 4. delete_wholesale_customer (soft) ───────────────────────────────────── -DROP FUNCTION IF EXISTS public.delete_wholesale_customer(UUID); -CREATE OR REPLACE FUNCTION public.delete_wholesale_customer(p_customer_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_customer RECORD; - v_order_count BIGINT; -BEGIN - -- Check customer exists - SELECT id, company_name INTO v_customer - FROM wholesale_customers WHERE id = p_customer_id FOR UPDATE; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Customer not found'); - END IF; - - -- Block if any orders exist (active or historical) - SELECT COUNT(*) INTO v_order_count - FROM wholesale_orders WHERE customer_id = p_customer_id; - IF v_order_count > 0 THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Cannot delete customer with existing orders. Disable the account instead.' - ); - END IF; - - -- Soft delete - UPDATE wholesale_customers SET deleted_at = now() WHERE id = p_customer_id; - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── 5. delete_wholesale_product (soft) ───────────────────────────────────── -DROP FUNCTION IF EXISTS public.delete_wholesale_product(UUID); -CREATE OR REPLACE FUNCTION public.delete_wholesale_product(p_product_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_product RECORD; - v_usage_count BIGINT; -BEGIN - -- Check product exists - SELECT id, name INTO v_product - FROM wholesale_products WHERE id = p_product_id FOR UPDATE; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Block if referenced in any order items - SELECT COUNT(*) INTO v_usage_count - FROM wholesale_order_items WHERE product_id = p_product_id; - IF v_usage_count > 0 THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Cannot delete product that is attached to orders. Set availability to unavailable instead.' - ); - END IF; - - -- Soft delete - UPDATE wholesale_products SET deleted_at = now() WHERE id = p_product_id; - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── 6. Strengthen delete_wholesale_order guard ────────────────────────────── -DROP FUNCTION IF EXISTS public.delete_wholesale_order(UUID); -CREATE OR REPLACE FUNCTION public.delete_wholesale_order(p_order_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order RECORD; -BEGIN - SELECT id, status, fulfillment_status, payment_status INTO v_order - FROM wholesale_orders WHERE id = p_order_id FOR UPDATE; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Order not found'); - END IF; - - IF v_order.fulfillment_status = 'fulfilled' THEN - RETURN jsonb_build_object('success', false, 'error', 'Cannot delete a fulfilled order.'); - END IF; - - IF v_order.payment_status = 'paid' THEN - RETURN jsonb_build_object('success', false, 'error', 'Cannot delete a paid order.'); - END IF; - - DELETE FROM wholesale_order_items WHERE wholesale_order_id = p_order_id; - DELETE FROM wholesale_notifications WHERE order_id = p_order_id; - DELETE FROM wholesale_orders WHERE id = p_order_id; - - RETURN jsonb_build_object('success', true); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/062_wholesale_webhooks.sql b/supabase/migrations/.archived/062_wholesale_webhooks.sql deleted file mode 100644 index b2731fa..0000000 --- a/supabase/migrations/.archived/062_wholesale_webhooks.sql +++ /dev/null @@ -1,195 +0,0 @@ --- Migration 062: Webhook dispatcher for wholesale events --- Adds webhook_settings table, sync_log table, enqueue RPC, and dispatch route - -BEGIN; - --- ── 1. wholesale_webhook_settings ────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.wholesale_webhook_settings ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - brand_id UUID NOT NULL REFERENCES public.brands(id) ON DELETE CASCADE, - url TEXT NOT NULL, - secret TEXT NOT NULL, -- HMAC signing secret - enabled BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (brand_id) -); - -ALTER TABLE public.wholesale_webhook_settings ENABLE ROW LEVEL SECURITY; -CREATE POLICY "admins_manage_webhook_settings" ON public.wholesale_webhook_settings - FOR ALL USING ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.brand_id = wholesale_webhook_settings.brand_id - AND au.role IN ('platform_admin', 'brand_admin') - ) - ); - --- ── 2. wholesale_sync_log ────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.wholesale_sync_log ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - brand_id UUID NOT NULL, - event_type TEXT NOT NULL, -- order_created | order_fulfilled | deposit_recorded | order_paid - order_id UUID, - payload JSONB, - status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | failed | retrying - response TEXT, - attempts INTEGER NOT NULL DEFAULT 0, - next_retry TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -ALTER TABLE public.wholesale_sync_log ENABLE ROW LEVEL SECURITY; -CREATE POLICY "admins_read_sync_log" ON public.wholesale_sync_log - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.brand_id = wholesale_sync_log.brand_id - AND au.role IN ('platform_admin', 'brand_admin', 'store_employee') - ) - ); - --- ── 3. enqueue_wholesale_webhook RPC ────────────────────────────────────── -DROP FUNCTION IF EXISTS public.enqueue_wholesale_webhook(TEXT, UUID, JSONB, UUID); -CREATE OR REPLACE FUNCTION public.enqueue_wholesale_webhook( - p_event_type TEXT, - p_order_id UUID DEFAULT NULL, - p_payload JSONB DEFAULT NULL, - p_brand_id UUID DEFAULT NULL -) -RETURNS UUID -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_log_id UUID; - v_brand_id UUID; -BEGIN - -- Use provided brand_id, or fall back to the first brand with webhooks enabled - IF p_brand_id IS NOT NULL THEN - SELECT brand_id INTO v_brand_id - FROM public.wholesale_webhook_settings - WHERE brand_id = p_brand_id AND enabled = true AND url IS NOT NULL AND url != '' - LIMIT 1; - ELSE - SELECT brand_id INTO v_brand_id - FROM public.wholesale_webhook_settings - WHERE enabled = true AND url IS NOT NULL AND url != '' - LIMIT 1; - END IF; - - IF NOT FOUND OR v_brand_id IS NULL THEN - -- Webhooks not enabled — silent no-op - RETURN NULL; - END IF; - - INSERT INTO public.wholesale_sync_log (brand_id, event_type, order_id, payload, status) - VALUES (v_brand_id, p_event_type, p_order_id, p_payload, 'pending') - RETURNING id INTO v_log_id; - - RETURN v_log_id; -END; -$$; - --- ── 4. get_pending_webhooks RPC ──────────────────────────────────────────── -DROP FUNCTION IF EXISTS public.get_pending_webhooks(INTEGER); -CREATE OR REPLACE FUNCTION public.get_pending_webhooks(p_limit INTEGER DEFAULT 20) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN COALESCE( - ( - SELECT jsonb_agg(r ORDER BY r.created_at ASC) - FROM ( - SELECT - wsl.id, - wsl.brand_id, - wsl.event_type, - wsl.order_id, - wsl.payload, - wsl.attempts, - wsl.status, - ws.url, - ws.secret - FROM wholesale_sync_log wsl - JOIN wholesale_webhook_settings ws ON ws.brand_id = wsl.brand_id AND ws.enabled = true - WHERE wsl.status IN ('pending', 'retrying') - AND (wsl.next_retry IS NULL OR wsl.next_retry <= now()) - ORDER BY wsl.created_at ASC - LIMIT p_limit - ) r - ), - '[]'::JSONB - ); -END; -$$; - --- ── 5. mark_webhook_sent RPC ─────────────────────────────────────────────── -DROP FUNCTION IF EXISTS public.mark_webhook_sent(UUID, TEXT); -CREATE OR REPLACE FUNCTION public.mark_webhook_sent(p_log_id UUID, p_response TEXT DEFAULT NULL) -RETURNS VOID -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE wholesale_sync_log - SET status = 'sent', response = p_response, attempts = attempts + 1 - WHERE id = p_log_id; -END; -$$; - --- ── 6. mark_webhook_failed RPC ───────────────────────────────────────────── -DROP FUNCTION IF EXISTS public.mark_webhook_failed(UUID, TEXT); -CREATE OR REPLACE FUNCTION public.mark_webhook_failed(p_log_id UUID, p_response TEXT DEFAULT NULL) -RETURNS VOID -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_attempts INTEGER; -BEGIN - SELECT attempts INTO v_attempts FROM wholesale_sync_log WHERE id = p_log_id FOR UPDATE; - IF v_attempts >= 3 THEN - UPDATE wholesale_sync_log - SET status = 'failed', response = p_response, attempts = attempts + 1 - WHERE id = p_log_id; - ELSE - UPDATE wholesale_sync_log - SET status = 'retrying', - response = p_response, - attempts = attempts + 1, - next_retry = now() + (attempts + 1 || ' hours')::INTERVAL - WHERE id = p_log_id; - END IF; -END; -$$; - --- ── 7. upsert_wholesale_webhook_settings RPC ───────────────────────────────── -DROP FUNCTION IF EXISTS public.upsert_wholesale_webhook_settings(UUID, TEXT, TEXT, BOOLEAN); -CREATE OR REPLACE FUNCTION public.upsert_wholesale_webhook_settings( - p_brand_id UUID, - p_url TEXT, - p_secret TEXT, - p_enabled BOOLEAN -) -RETURNS UUID -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_id UUID; -BEGIN - INSERT INTO public.wholesale_webhook_settings (brand_id, url, secret, enabled) - VALUES (p_brand_id, p_url, p_secret, p_enabled) - ON CONFLICT (brand_id) DO UPDATE SET - url = EXCLUDED.url, - secret = EXCLUDED.secret, - enabled = EXCLUDED.enabled, - updated_at = now() - RETURNING id INTO v_id; - RETURN v_id; -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/063_auto_create_wholesale_settings.sql b/supabase/migrations/.archived/063_auto_create_wholesale_settings.sql deleted file mode 100644 index 6fb28b4..0000000 --- a/supabase/migrations/.archived/063_auto_create_wholesale_settings.sql +++ /dev/null @@ -1,67 +0,0 @@ --- Migration 063: Auto-create wholesale_settings on first registration --- Ensures new brands get a default wholesale_settings row automatically - -BEGIN; - --- Update register_wholesale_customer to auto-create wholesale_settings if missing -CREATE OR REPLACE FUNCTION public.register_wholesale_customer( - p_brand_id UUID DEFAULT NULL, - p_company_name TEXT DEFAULT NULL, - p_contact_name TEXT DEFAULT NULL, - p_email TEXT DEFAULT NULL, - p_phone TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_id UUID; - v_req_app BOOLEAN := true; -BEGIN - -- Reject duplicate email for this brand - IF EXISTS (SELECT 1 FROM wholesale_customers WHERE brand_id = p_brand_id AND email = p_email) THEN - RETURN jsonb_build_object('success', false, 'error', 'An account with this email already exists.'); - END IF; - - -- Auto-create wholesale_settings row if it doesn't exist for this brand - -- This ensures new brands always have settings before registration proceeds - IF NOT EXISTS (SELECT 1 FROM wholesale_settings WHERE brand_id = p_brand_id) THEN - INSERT INTO wholesale_settings (brand_id, require_approval, wholesale_enabled, pickup_location) - VALUES (p_brand_id, true, true, 'See your order confirmation for pickup details') - ON CONFLICT (brand_id) DO NOTHING; - END IF; - - -- Read require_approval setting for this brand (now guaranteed to exist) - BEGIN - SELECT require_approval INTO v_req_app - FROM wholesale_settings - WHERE brand_id = p_brand_id; - EXCEPTION WHEN OTHERS THEN - v_req_app := true; - END; - - INSERT INTO wholesale_customers ( - brand_id, company_name, contact_name, email, phone, - account_status, role - ) VALUES ( - p_brand_id, - p_company_name, - p_contact_name, - p_email, - p_phone, - CASE WHEN COALESCE(v_req_app, true) THEN 'pending_approval' ELSE 'active' END, - 'buyer' - ) - RETURNING id INTO v_id; - - RETURN jsonb_build_object( - 'success', true, - 'id', v_id, - 'requires_approval', COALESCE(v_req_app, true) - ); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/064_seed_wholesale_settings.sql b/supabase/migrations/.archived/064_seed_wholesale_settings.sql deleted file mode 100644 index 3ed793d..0000000 --- a/supabase/migrations/.archived/064_seed_wholesale_settings.sql +++ /dev/null @@ -1,163 +0,0 @@ --- Migration 064: Seed wholesale_settings for existing brands + fix RLS for service role --- Also creates a seed function that bypasses RLS for initial setup - -BEGIN; - --- Create a SECURITY DEFINER function that inserts wholesale_settings without RLS --- This is only for initial seeding — not used in normal operation -CREATE OR REPLACE FUNCTION public.seed_wholesale_settings( - p_brand_id UUID, - p_require_approval BOOLEAN DEFAULT true, - p_wholesale_enabled BOOLEAN DEFAULT true, - p_pickup_location TEXT DEFAULT NULL, - p_from_email TEXT DEFAULT NULL, - p_invoice_business_name TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO wholesale_settings ( - brand_id, require_approval, wholesale_enabled, - pickup_location, from_email, invoice_business_name - ) - VALUES ( - p_brand_id, - COALESCE(p_require_approval, true), - COALESCE(p_wholesale_enabled, true), - p_pickup_location, - p_from_email, - p_invoice_business_name - ) - ON CONFLICT (brand_id) DO UPDATE SET - require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval), - wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled), - pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location), - from_email = COALESCE(p_from_email, wholesale_settings.from_email), - invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name), - updated_at = now() - RETURNING jsonb_build_object('success', true, 'brand_id', brand_id); -END; -$$; - --- Update register_wholesale_customer to auto-create settings if missing --- This ensures new brands always get settings on first registration -CREATE OR REPLACE FUNCTION public.register_wholesale_customer( - p_brand_id UUID DEFAULT NULL, - p_company_name TEXT DEFAULT NULL, - p_contact_name TEXT DEFAULT NULL, - p_email TEXT DEFAULT NULL, - p_phone TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_id UUID; - v_req_app BOOLEAN := true; -BEGIN - -- Reject duplicate email for this brand - IF EXISTS (SELECT 1 FROM wholesale_customers WHERE brand_id = p_brand_id AND email = p_email) THEN - RETURN jsonb_build_object('success', false, 'error', 'An account with this email already exists.'); - END IF; - - -- Auto-create wholesale_settings row if it doesn't exist for this brand - -- Uses SECURITY DEFINER to bypass RLS — so it works even without service role - IF NOT EXISTS (SELECT 1 FROM wholesale_settings WHERE brand_id = p_brand_id) THEN - INSERT INTO wholesale_settings (brand_id, require_approval, wholesale_enabled, pickup_location) - VALUES (p_brand_id, true, true, 'See your order confirmation for pickup details') - ON CONFLICT (brand_id) DO NOTHING; - END IF; - - -- Read require_approval setting for this brand - BEGIN - SELECT require_approval INTO v_req_app - FROM wholesale_settings - WHERE brand_id = p_brand_id; - EXCEPTION WHEN OTHERS THEN - v_req_app := true; - END; - - INSERT INTO wholesale_customers ( - brand_id, company_name, contact_name, email, phone, - account_status, role - ) VALUES ( - p_brand_id, - p_company_name, - p_contact_name, - p_email, - p_phone, - CASE WHEN COALESCE(v_req_app, true) THEN 'pending_approval' ELSE 'active' END, - 'buyer' - ) - RETURNING id INTO v_id; - - RETURN jsonb_build_object( - 'success', true, - 'id', v_id, - 'requires_approval', COALESCE(v_req_app, true) - ); -END; -$$; - --- Also fix get_wholesale_settings to return defaults if no settings exist --- This prevents 406 errors when settings are missing -CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_build_object( - 'id', id, - 'brand_id', brand_id, - 'portal_page_id', portal_page_id, - 'price_sheet_page_id', price_sheet_page_id, - 'require_approval', require_approval, - 'min_order_amount', min_order_amount, - 'online_payment_enabled', online_payment_enabled, - 'wholesale_enabled', COALESCE(wholesale_enabled, true), - 'pickup_location', COALESCE(pickup_location, 'Contact us for pickup details'), - 'fob_location', fob_location, - 'from_email', from_email, - 'invoice_business_name', COALESCE(invoice_business_name, 'Wholesale'), - 'invoice_business_address', invoice_business_address, - 'invoice_business_phone', invoice_business_phone, - 'invoice_business_email', invoice_business_email, - 'invoice_business_website', invoice_business_website, - 'last_invoice_number', last_invoice_number - ) INTO v_result - FROM wholesale_settings - WHERE brand_id = p_brand_id; - - -- If still null, return safe defaults so client code doesn't break - IF v_result IS NULL THEN - RETURN jsonb_build_object( - 'id', NULL, - 'brand_id', p_brand_id, - 'portal_page_id', NULL, - 'price_sheet_page_id', NULL, - 'require_approval', true, - 'min_order_amount', 0, - 'online_payment_enabled', false, - 'wholesale_enabled', true, - 'pickup_location', 'Contact us for pickup details', - 'fob_location', NULL, - 'from_email', NULL, - 'invoice_business_name', 'Wholesale', - 'invoice_business_address', NULL, - 'invoice_business_phone', NULL, - 'invoice_business_email', NULL, - 'invoice_business_website', NULL, - 'last_invoice_number', 0 - ); - END IF; - - RETURN v_result; -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/065_fix_get_wholesale_products.sql b/supabase/migrations/.archived/065_fix_get_wholesale_products.sql deleted file mode 100644 index b5ad3f0..0000000 --- a/supabase/migrations/.archived/065_fix_get_wholesale_products.sql +++ /dev/null @@ -1,43 +0,0 @@ --- Migration 065: Fix get_wholesale_products — remove rc_product_id reference --- The wholesale_products table does not have rc_product_id column, --- but get_wholesale_products RPC (migration 061) references it. --- Also drop the orphaned rc_product_id column if it exists. - -BEGIN; - --- Drop rc_product_id column if it somehow got added (migration 046 planned it but it was never created) --- This is safe IF NOT EXISTS since the column likely doesn't exist -ALTER TABLE public.wholesale_products DROP COLUMN IF EXISTS rc_product_id; - --- Recreate get_wholesale_products without rc_product_id reference -DROP FUNCTION IF EXISTS public.get_wholesale_products(UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.name) INTO v_result - FROM ( - SELECT - id, brand_id, name, description, - unit_type, unit_type_custom, availability, qty_available, - season_start, season_end, price_tiers, hp_sku, hp_item_id, - handling_instructions, storage_warning, loading_notes, - product_label, pack_style, container_type, container_size_code, - units_per_container, default_pickup_location, created_at, updated_at, - deleted_at - FROM wholesale_products - WHERE brand_id = p_brand_id - AND deleted_at IS NULL - ORDER BY name - LIMIT 500 - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/066_auto_square_sync.sql b/supabase/migrations/.archived/066_auto_square_sync.sql deleted file mode 100644 index 575c230..0000000 --- a/supabase/migrations/.archived/066_auto_square_sync.sql +++ /dev/null @@ -1,146 +0,0 @@ --- Migration 066: Auto Square Sync — queue table, enqueue trigger, claim processor --- Applies: square_sync_queue table, enqueue_square_sync() RPC, --- trg_wholesale_products_sync trigger, queue processor endpoint - -BEGIN; - --- ── 1. Sync queue table ────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.square_sync_queue ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - sync_type TEXT NOT NULL DEFAULT 'products', - status TEXT NOT NULL DEFAULT 'pending', - enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(), - processed_at TIMESTAMPTZ, - retry_count INTEGER NOT NULL DEFAULT 0, - last_error TEXT, - CONSTRAINT square_sync_queue_status CHECK (status IN ('pending', 'processing', 'done', 'failed')) -); - -CREATE INDEX IF NOT EXISTS idx_square_sync_queue_pending - ON square_sync_queue(status, enqueued_at ASC) - WHERE status = 'pending'; - --- ── 2. enqueue_square_sync — idempotent, safe for trigger ───────────────────── -CREATE OR REPLACE FUNCTION public.enqueue_square_sync( - p_brand_id UUID, - p_sync_type TEXT DEFAULT 'products' -) -RETURNS void -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM square_sync_queue - WHERE brand_id = p_brand_id - AND sync_type = p_sync_type - AND status IN ('pending', 'processing') - ) THEN - INSERT INTO square_sync_queue (brand_id, sync_type, status) - VALUES (p_brand_id, p_sync_type, 'pending'); - END IF; -END; -$$; - --- ── 3. claim_square_sync_queue — atomic claim + return ──────────────────────── --- Atomically claims the oldest pending entry for a brand, marks it 'processing', --- and returns it so the processor can work on it. -CREATE OR REPLACE FUNCTION public.claim_square_sync_queue(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_entry JSONB; -BEGIN - -- Claim and update in one atomic operation - WITH claimed AS ( - UPDATE square_sync_queue - SET status = 'processing', processed_at = now() - WHERE id = ( - SELECT id FROM square_sync_queue - WHERE brand_id = p_brand_id - AND status = 'pending' - ORDER BY enqueued_at ASC - LIMIT 1 - FOR UPDATE SKIP LOCKED - ) - RETURNING id, brand_id, sync_type, status, enqueued_at - ) - SELECT jsonb_build_object( - 'id', id, - 'brand_id', brand_id, - 'sync_type', sync_type, - 'status', status, - 'enqueued_at', enqueued_at - ) INTO v_entry - FROM claimed; - - RETURN COALESCE(v_entry, 'null'::JSONB); -END; -$$; - --- ── 4. Triggers on wholesale_products ───────────────────────────────────────── -CREATE OR REPLACE FUNCTION public.trg_wholesale_products_on_change() -RETURNS TRIGGER -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - IF (TG_OP = 'INSERT') THEN - IF NEW.availability = 'available' THEN - PERFORM enqueue_square_sync(NEW.brand_id, 'products'); - END IF; - RETURN NEW; - END IF; - - IF (TG_OP = 'UPDATE') THEN - IF OLD.availability = 'available' OR NEW.availability = 'available' THEN - PERFORM enqueue_square_sync(NEW.brand_id, 'products'); - END IF; - RETURN NEW; - END IF; - - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS trg_wholesale_products_sync ON wholesale_products; -CREATE TRIGGER trg_wholesale_products_sync - AFTER INSERT OR UPDATE OF availability, name, description, price_tiers, unit_type, default_pickup_location - ON wholesale_products - FOR EACH ROW EXECUTE FUNCTION trg_wholesale_products_on_change(); - -CREATE OR REPLACE FUNCTION public.trg_wholesale_products_on_delete() -RETURNS TRIGGER -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - PERFORM enqueue_square_sync(OLD.brand_id, 'products'); - RETURN OLD; -END; -$$; - -DROP TRIGGER IF EXISTS trg_wholesale_products_sync_del ON wholesale_products; -CREATE TRIGGER trg_wholesale_products_sync_del - AFTER DELETE ON wholesale_products - FOR EACH ROW EXECUTE FUNCTION trg_wholesale_products_on_delete(); - --- ── 5. Upsert payment_settings with square_last_sync_at ───────────────────── --- Helper RPC to update last sync timestamp after a sync completes -CREATE OR REPLACE FUNCTION public.update_square_sync_timestamp( - p_brand_id UUID, - p_error TEXT DEFAULT NULL -) -RETURNS void -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE payment_settings - SET square_last_sync_at = now(), - square_last_sync_error = p_error - WHERE brand_id = p_brand_id; -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/067_checkout_session_id.sql b/supabase/migrations/.archived/067_checkout_session_id.sql deleted file mode 100644 index 32d4637..0000000 --- a/supabase/migrations/.archived/067_checkout_session_id.sql +++ /dev/null @@ -1,160 +0,0 @@ --- Migration 067: Checkout idempotency for wholesale orders --- Adds checkout_session_id UUID to wholesale_orders for safe retry/double-submit protection. --- UNIQUE constraint (null-excluding) ensures each session maps to at most one order. --- The create_wholesale_order RPC checks this key before inserting to prevent duplicates. - -BEGIN; - -ALTER TABLE public.wholesale_orders - ADD COLUMN IF NOT EXISTS checkout_session_id UUID; - -COMMENT ON COLUMN wholesale_orders.checkout_session_id IS - 'Set by submitWholesaleOrder at start of checkout. Prevents duplicate order creation from double-click, back-button, or network retry. UNIQUE WHERE NOT NULL.'; - --- Fast lookup for idempotency checks in create_wholesale_order -CREATE UNIQUE INDEX IF NOT EXISTS idx_wholesale_orders_checkout_session_id - ON wholesale_orders(checkout_session_id) - WHERE checkout_session_id IS NOT NULL; - --- Update create_wholesale_order to accept + enforce checkout_session_id -DROP FUNCTION IF EXISTS public.create_wholesale_order( - UUID, UUID, DATE, JSONB, INTEGER, TEXT -); -CREATE OR REPLACE FUNCTION public.create_wholesale_order( - p_brand_id UUID DEFAULT NULL, - p_customer_id UUID DEFAULT NULL, - p_anticipated_pickup_date DATE DEFAULT NULL, - p_items JSONB DEFAULT '[]'::JSONB, - p_deposit_percentage INTEGER DEFAULT NULL, - p_notes TEXT DEFAULT NULL, - p_checkout_session_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order_id UUID; - v_subtotal NUMERIC(10,2); - v_dep_required NUMERIC(10,2) := 0; - v_customer RECORD; - v_settings RECORD; - v_inv_number TEXT; - v_inv_token TEXT; - v_status TEXT; - v_outstanding NUMERIC(10,2); - v_existing_id UUID; -BEGIN - -- ── Idempotency guard ────────────────────────────────────────────────────── - -- If a checkout_session_id was provided and an order already exists with - -- that key, return the existing order instead of creating a duplicate. - IF p_checkout_session_id IS NOT NULL THEN - SELECT id INTO v_existing_id - FROM wholesale_orders - WHERE checkout_session_id = p_checkout_session_id - LIMIT 1; - - IF v_existing_id IS NOT NULL THEN - SELECT jsonb_build_object( - 'success', true, - 'order_id', v_existing_id, - 'invoice_number', invoice_number, - 'status', status, - 'deposit_required', deposit_required, - 'subtotal', subtotal, - 'idempotent', true, - 'note', 'Order already exists — returning existing record' - ) INTO v_existing_id - FROM wholesale_orders - WHERE id = v_existing_id; - RETURN v_existing_id; - END IF; - END IF; - - -- ── 1. Resolve customer: credit_limit + deposit rules ────────────────────── - SELECT credit_limit, deposits_enabled, deposit_threshold, deposit_percentage - INTO v_customer - FROM wholesale_customers - WHERE id = p_customer_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Customer not found'); - END IF; - - -- ── 2. Calculate subtotal ────────────────────────────────────────────────── - v_subtotal := COALESCE( - (SELECT SUM( - ((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC) - ) - FROM jsonb_array_elements(p_items) AS item), - 0 - ); - - -- ── 3. Credit limit enforcement ──────────────────────────────────────────── - IF v_customer.credit_limit > 0 THEN - SELECT COALESCE(SUM(balance_due), 0) INTO v_outstanding - FROM wholesale_orders - WHERE customer_id = p_customer_id AND status != 'fulfilled'; - - IF v_outstanding + v_subtotal > v_customer.credit_limit THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Order exceeds available credit', - 'credit_limit', v_customer.credit_limit, - 'outstanding_balance', v_outstanding, - 'order_total', v_subtotal - ); - END IF; - END IF; - - -- ── 4. Generate TC-XXXXXX invoice + md5 token ────────────────────────────── - v_inv_number := 'TC-' || LPAD(FLOOR(RANDOM() * 1000000)::INT::TEXT, 6, '0'); - v_inv_token := md5(random()::text || clock_timestamp()::text || now()::text || p_brand_id::text); - - -- ── 5. Deposit logic ─────────────────────────────────────────────────────── - IF v_customer.deposits_enabled THEN - IF v_customer.deposit_threshold IS NULL OR v_subtotal >= v_customer.deposit_threshold THEN - v_dep_required := ROUND(v_subtotal * COALESCE(v_customer.deposit_percentage, 0) / 100.0, 2); - END IF; - END IF; - - v_status := CASE WHEN v_dep_required > 0 THEN 'awaiting_deposit' ELSE 'pending' END; - - -- ── 6. Insert order ───────────────────────────────────────────────────────── - INSERT INTO wholesale_orders ( - brand_id, customer_id, status, anticipated_pickup_date, - subtotal, deposit_required, deposit_paid, balance_due, - deposit_percentage, internal_notes, invoice_number, invoice_token, - checkout_session_id - ) VALUES ( - p_brand_id, p_customer_id, v_status, p_anticipated_pickup_date, - v_subtotal, v_dep_required, 0, v_subtotal, - p_deposit_percentage, p_notes, v_inv_number, v_inv_token, - p_checkout_session_id - ) RETURNING id INTO v_order_id; - - -- ── 7. Insert line items ─────────────────────────────────────────────────── - IF jsonb_array_length(p_items) > 0 THEN - INSERT INTO wholesale_order_items (wholesale_order_id, product_id, quantity, unit_price, line_total) - SELECT v_order_id, - (item->>'product_id')::UUID, - (item->>'quantity')::NUMERIC, - (item->>'unit_price')::NUMERIC, - ((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC) - FROM jsonb_array_elements(p_items) AS item; - END IF; - - RETURN jsonb_build_object( - 'success', true, - 'order_id', v_order_id, - 'invoice_number', v_inv_number, - 'status', v_status, - 'deposit_required', v_dep_required, - 'subtotal', v_subtotal, - 'idempotent', false - ); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/068_user_carts.sql b/supabase/migrations/.archived/068_user_carts.sql deleted file mode 100644 index aa6e7e2..0000000 --- a/supabase/migrations/.archived/068_user_carts.sql +++ /dev/null @@ -1,67 +0,0 @@ --- Migration 068: User carts table for server-side cart persistence --- Enables cart merge on login and server-side cart for logged-in users. - -BEGIN; - -CREATE TABLE IF NOT EXISTS public.user_carts ( - user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, - items JSONB NOT NULL DEFAULT '[]'::JSONB, - -- Shape: [{ id, name, price, quantity, fulfillment, brand_id, brand_slug }] - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -ALTER TABLE public.user_carts ENABLE ROW LEVEL SECURITY; - --- Users can read/write their own cart; platform_admin can read all -CREATE POLICY "users_own_cart" ON public.user_carts - FOR ALL USING ( - auth.uid() = user_id - OR current_setting('app.settings.role', true)::TEXT = 'platform_admin' - ); - --- RPC: upsert_user_cart — insert or update the user's cart --- p_user_id: optional override (platform_admin may pass to update another user's cart) -CREATE OR REPLACE FUNCTION public.upsert_user_cart( - p_user_id UUID, - p_items JSONB -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO user_carts (user_id, items, updated_at) - VALUES (p_user_id, p_items, now()) - ON CONFLICT (user_id) DO UPDATE SET - items = p_items, - updated_at = now() - RETURNING jsonb_build_object('success', true, 'user_id', user_id, 'item_count', jsonb_array_length(p_items)); -END; -$$; - --- RPC: get_user_cart — fetch a user's cart -CREATE OR REPLACE FUNCTION public.get_user_cart(p_user_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_items JSONB; -BEGIN - SELECT items INTO v_items FROM user_carts WHERE user_id = p_user_id; - RETURN COALESCE(v_items, '[]'::JSONB); -END; -$$; - --- RPC: clear_user_cart -CREATE OR REPLACE FUNCTION public.clear_user_cart(p_user_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - DELETE FROM user_carts WHERE user_id = p_user_id; - RETURN jsonb_build_object('success', true); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/069_brand_scoping_phase2.sql b/supabase/migrations/.archived/069_brand_scoping_phase2.sql deleted file mode 100644 index 4a90db9..0000000 --- a/supabase/migrations/.archived/069_brand_scoping_phase2.sql +++ /dev/null @@ -1,606 +0,0 @@ --- Migration 069: Brand Scoping Phase 2 — RLS Fixes + RPC Validation --- 1. Fix wholesale_order_items RLS — add brand check via JOIN --- 2. Fix wholesale_deposits RLS — add brand check via JOIN --- 3. Fix get_wholesale_orders/customers/products NULL handling (platform_admin = all) --- 4. Add brand validation to update_shipping_order --- 5. Add brand validation to water log mutating RPCs - -BEGIN; - --- ── 1. Fix wholesale_order_items RLS ───────────────────────────────────────── --- Current policy has no brand check. Add one via JOIN to wholesale_orders. -DROP POLICY IF EXISTS "brand_admin_manage_wholesale_order_items" ON wholesale_order_items; -ALTER TABLE wholesale_order_items DISABLE ROW LEVEL SECURITY; -ALTER TABLE wholesale_order_items ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "brand_admin_manage_wholesale_order_items" ON wholesale_order_items - FOR ALL USING ( - -- platform_admin can do anything - current_setting('app.settings.role', true)::TEXT = 'platform_admin' - OR ( - -- brand_admin must match the order's brand via JOIN - current_setting('app.settings.role', true)::TEXT = 'brand_admin' - AND EXISTS ( - SELECT 1 FROM wholesale_orders wo - WHERE wo.id = wholesale_order_items.wholesale_order_id - AND wo.brand_id = current_setting('app.settings.brand_id', true)::UUID - ) - ) - ); - --- ── 2. Fix wholesale_deposits RLS ───────────────────────────────────────────── -DROP POLICY IF EXISTS "brand_admin_manage_wholesale_deposits" ON wholesale_deposits; -ALTER TABLE wholesale_deposits DISABLE ROW LEVEL SECURITY; -ALTER TABLE wholesale_deposits ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "brand_admin_manage_wholesale_deposits" ON wholesale_deposits - FOR ALL USING ( - current_setting('app.settings.role', true)::TEXT = 'platform_admin' - OR ( - current_setting('app.settings.role', true)::TEXT = 'brand_admin' - AND EXISTS ( - SELECT 1 FROM wholesale_orders wo - WHERE wo.id = wholesale_deposits.wholesale_order_id - AND wo.brand_id = current_setting('app.settings.brand_id', true)::UUID - ) - ) - ); - --- ── 3. Fix get_wholesale_orders NULL handling ──────────────────────────────── --- NULL brand_id should return ALL orders (platform_admin); set brand returns scoped. -CREATE OR REPLACE FUNCTION public.get_wholesale_orders(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result - FROM ( - SELECT - wo.id, - wo.status, - wo.fulfillment_status, - wo.payment_status, - wo.anticipated_pickup_date, - wo.subtotal, - wo.deposit_required, - wo.deposit_paid, - wo.balance_due, - wo.invoice_number, - wo.invoice_token, - wo.checkout_session_id, - wo.created_at, - wo.updated_at, - wo.fulfilled_at, - wo.notes, - wc.company_name, - wc.contact_name, - wc.email AS customer_email, - wc.phone AS customer_phone, - COALESCE( - (SELECT jsonb_agg( - CASE WHEN woi.id IS NOT NULL THEN - jsonb_build_object( - 'id', woi.id, - 'product_name', wp.name, - 'quantity', woi.quantity, - 'unit_price', woi.unit_price, - 'line_total', woi.line_total - ) - END - ) - FROM wholesale_order_items woi - LEFT JOIN wholesale_products wp ON woi.product_id = wp.id - WHERE woi.wholesale_order_id = wo.id), - '[]'::JSONB - ) AS items - FROM wholesale_orders wo - JOIN wholesale_customers wc ON wo.customer_id = wc.id - WHERE ( - -- NULL brand_id = platform_admin = all brands - p_brand_id IS NULL - OR wo.brand_id = p_brand_id - ) - ORDER BY wo.created_at DESC - LIMIT 500 - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - --- ── 4. Fix get_wholesale_customers NULL handling ──────────────────────────── -CREATE OR REPLACE FUNCTION public.get_wholesale_customers(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.company_name ASC) INTO v_result - FROM ( - SELECT - id, - user_id, - company_name, - contact_name, - email, - phone, - account_status, - role, - brand_id, - credit_limit, - outstanding_balance, - created_at, - updated_at - FROM wholesale_customers - WHERE p_brand_id IS NULL OR brand_id = p_brand_id - ORDER BY company_name ASC - LIMIT 500 - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - --- ── 5. Fix get_wholesale_products NULL handling ───────────────────────────── -CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(t ORDER BY t.name ASC) INTO v_result - FROM ( - SELECT - id, - brand_id, - name, - description, - unit_type, - availability, - qty_available, - hp_sku, - created_at, - updated_at, - price_tiers - FROM wholesale_products - WHERE p_brand_id IS NULL OR brand_id = p_brand_id - ORDER BY name ASC - LIMIT 500 - ) t; - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - --- ── 6. Add brand validation to update_shipping_order ───────────────────────── -CREATE OR REPLACE FUNCTION public.update_shipping_order( - p_order_id UUID, - p_shipping_status TEXT, - p_tracking_number TEXT DEFAULT NULL, - p_brand_id UUID DEFAULT NULL -- NEW: for brand validation -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order RECORD; -BEGIN - -- Fetch order and validate brand access - SELECT id, brand_id INTO v_order - FROM orders - WHERE id = p_order_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Order not found'); - END IF; - - -- Brand validation: brand_admin can only update orders for their brand - IF p_brand_id IS NOT NULL AND v_order.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to update this order'); - END IF; - - UPDATE orders SET - shipping_status = p_shipping_status, - tracking_number = COALESCE(p_tracking_number, tracking_number), - updated_at = now() - WHERE id = p_order_id; - - RETURN jsonb_build_object( - 'success', true, - 'order_id', p_order_id, - 'shipping_status', p_shipping_status, - 'tracking_number', COALESCE(p_tracking_number, tracking_number) - ); -END; -$$; - --- ── 7. Add brand validation to water log mutating RPCs ────────────────────── - --- update_water_entry: add p_brand_id, validate -CREATE OR REPLACE FUNCTION public.update_water_entry( - p_entry_id UUID, - p_measurement NUMERIC, - p_notes TEXT DEFAULT NULL, - p_unit TEXT DEFAULT NULL, - p_brand_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_entry RECORD; -BEGIN - SELECT we.id, we.brand_id INTO v_entry - FROM water_log_entries we - WHERE we.id = p_entry_id AND we.deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Entry not found'); - END IF; - - -- Brand validation - IF p_brand_id IS NOT NULL AND v_entry.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to update this entry'); - END IF; - - UPDATE water_log_entries SET - measurement = p_measurement, - notes = COALESCE(p_notes, notes), - unit = COALESCE(p_unit, unit), - updated_at = now() - WHERE id = p_entry_id; - - RETURN jsonb_build_object('success', true, 'entry_id', p_entry_id); -END; -$$; - --- delete_water_entry: add p_brand_id -CREATE OR REPLACE FUNCTION public.delete_water_entry( - p_entry_id UUID, - p_brand_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_entry RECORD; -BEGIN - SELECT id, brand_id INTO v_entry - FROM water_log_entries - WHERE id = p_entry_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Entry not found'); - END IF; - - IF p_brand_id IS NOT NULL AND v_entry.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to delete this entry'); - END IF; - - UPDATE water_log_entries SET deleted_at = now() WHERE id = p_entry_id; - RETURN jsonb_build_object('success', true); -END; -$$; - --- update_water_headgate: add p_brand_id -CREATE OR REPLACE FUNCTION public.update_water_headgate( - p_headgate_id UUID, - p_name TEXT, - p_active BOOLEAN DEFAULT true, - p_unit TEXT DEFAULT NULL, - p_brand_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_hg RECORD; -BEGIN - SELECT id, brand_id INTO v_hg - FROM water_headgates - WHERE id = p_headgate_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Headgate not found'); - END IF; - - IF p_brand_id IS NOT NULL AND v_hg.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to update this headgate'); - END IF; - - UPDATE water_headgates SET - name = p_name, - active = p_active, - unit = COALESCE(p_unit, unit), - updated_at = now() - WHERE id = p_headgate_id; - - RETURN jsonb_build_object('success', true, 'headgate_id', p_headgate_id); -END; -$$; - --- delete_water_headgate: add p_brand_id -CREATE OR REPLACE FUNCTION public.delete_water_headgate( - p_headgate_id UUID, - p_brand_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_hg RECORD; -BEGIN - SELECT id, brand_id INTO v_hg - FROM water_headgates - WHERE id = p_headgate_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Headgate not found'); - END IF; - - IF p_brand_id IS NOT NULL AND v_hg.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to delete this headgate'); - END IF; - - UPDATE water_headgates SET deleted_at = now() WHERE id = p_headgate_id; - RETURN jsonb_build_object('success', true); -END; -$$; - --- update_water_user: add p_brand_id -CREATE OR REPLACE FUNCTION public.update_water_user( - p_user_id UUID, - p_name TEXT DEFAULT NULL, - p_active BOOLEAN DEFAULT NULL, - p_lang TEXT DEFAULT NULL, - p_role TEXT DEFAULT NULL, - p_brand_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_user RECORD; -BEGIN - SELECT id, brand_id INTO v_user - FROM water_users - WHERE id = p_user_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'User not found'); - END IF; - - IF p_brand_id IS NOT NULL AND v_user.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to update this user'); - END IF; - - UPDATE water_users SET - name = COALESCE(p_name, name), - active = COALESCE(p_active, active), - lang = COALESCE(p_lang, lang), - role = COALESCE(p_role, role), - updated_at = now() - WHERE id = p_user_id; - - RETURN jsonb_build_object('success', true, 'user_id', p_user_id); -END; -$$; - --- delete_water_user: add p_brand_id -CREATE OR REPLACE FUNCTION public.delete_water_user( - p_user_id UUID, - p_brand_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_user RECORD; -BEGIN - SELECT id, brand_id INTO v_user - FROM water_users - WHERE id = p_user_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'User not found'); - END IF; - - IF p_brand_id IS NOT NULL AND v_user.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to delete this user'); - END IF; - - UPDATE water_users SET deleted_at = now() WHERE id = p_user_id; - RETURN jsonb_build_object('success', true); -END; -$$; - --- reset_water_irrigator_pin: add p_brand_id via join check -CREATE OR REPLACE FUNCTION public.reset_water_irrigator_pin( - p_user_id UUID, - p_brand_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_user RECORD; -BEGIN - SELECT id, brand_id INTO v_user - FROM water_users - WHERE id = p_user_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'User not found'); - END IF; - - IF p_brand_id IS NOT NULL AND v_user.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to reset this PIN'); - END IF; - - UPDATE water_users SET - pin_hash = NULL, - updated_at = now() - WHERE id = p_user_id; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- reset_water_irrigator_pin: add p_brand_id via join check -CREATE OR REPLACE FUNCTION public.reset_water_irrigator_pin( - p_user_id UUID, - p_brand_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_user RECORD; -BEGIN - SELECT id, brand_id INTO v_user - FROM water_users - WHERE id = p_user_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'User not found'); - END IF; - - IF p_brand_id IS NOT NULL AND v_user.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to reset this PIN'); - END IF; - - UPDATE water_users SET - pin_hash = NULL, - updated_at = now() - WHERE id = p_user_id; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── 8. Add brand validation to record_wholesale_deposit ──────────────────── -CREATE OR REPLACE FUNCTION public.record_wholesale_deposit( - p_order_id UUID, - p_amount NUMERIC, - p_method TEXT DEFAULT 'cash', - p_reference TEXT DEFAULT NULL, - p_recorded_by UUID DEFAULT NULL, - p_brand_id UUID DEFAULT NULL -- NEW -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order RECORD; -BEGIN - SELECT id, brand_id INTO v_order - FROM wholesale_orders - WHERE id = p_order_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Order not found'); - END IF; - - IF p_brand_id IS NOT NULL AND v_order.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to record deposit for this order'); - END IF; - - INSERT INTO wholesale_deposits (wholesale_order_id, amount, method, reference, recorded_by) - VALUES (p_order_id, p_amount, p_method, p_reference, p_recorded_by); - - UPDATE wholesale_orders SET - deposit_paid = deposit_paid + p_amount, - payment_status = CASE - WHEN deposit_paid + p_amount >= deposit_required THEN 'deposit_paid' - ELSE 'partial_deposit' - END, - updated_at = now() - WHERE id = p_order_id; - - RETURN jsonb_build_object('success', true, 'order_id', p_order_id, 'amount', p_amount); -END; -$$; - --- ── 9. Add brand validation to bulk_fulfill_wholesale_orders ───────────────── -CREATE OR REPLACE FUNCTION public.bulk_fulfill_wholesale_orders( - p_order_ids UUID[], - p_by UUID DEFAULT NULL, - p_brand_id UUID DEFAULT NULL -- NEW -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_count INT; -BEGIN - -- Brand scoping: if brand_id provided, validate all orders belong to it - IF p_brand_id IS NOT NULL THEN - SELECT COUNT(*) INTO v_count - FROM wholesale_orders - WHERE id = ANY(p_order_ids) AND brand_id != p_brand_id; - IF v_count > 0 THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to fulfill these orders'); - END IF; - END IF; - - UPDATE wholesale_orders SET - fulfillment_status = 'fulfilled', - fulfilled_at = now(), - fulfilled_by = p_by, - updated_at = now() - WHERE id = ANY(p_order_ids) AND fulfillment_status != 'fulfilled'; - - GET DIAGNOSTICS v_count = ROW_COUNT; - RETURN jsonb_build_object('success', true, 'count', v_count); -END; -$$; - --- ── 10. Add brand validation to bulk_record_wholesale_deposit ─────────────── -CREATE OR REPLACE FUNCTION public.bulk_record_wholesale_deposit( - p_order_ids UUID[], - p_amount NUMERIC, - p_method TEXT DEFAULT 'cash', - p_reference TEXT DEFAULT NULL, - p_recorded_by UUID DEFAULT NULL, - p_brand_id UUID DEFAULT NULL -- NEW -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_count INT; - v_order RECORD; -BEGIN - -- Brand scoping - IF p_brand_id IS NOT NULL THEN - SELECT COUNT(*) INTO v_count - FROM wholesale_orders - WHERE id = ANY(p_order_ids) AND brand_id != p_brand_id; - IF v_count > 0 THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to record deposits for these orders'); - END IF; - END IF; - - INSERT INTO wholesale_deposits (wholesale_order_id, amount, method, reference, recorded_by) - SELECT o.id, p_amount, p_method, p_reference, p_recorded_by - FROM wholesale_orders o - WHERE o.id = ANY(p_order_ids); - - UPDATE wholesale_orders SET - deposit_paid = deposit_paid + p_amount, - payment_status = CASE - WHEN deposit_paid + p_amount >= deposit_required THEN 'deposit_paid' - ELSE 'partial_deposit' - END, - updated_at = now() - WHERE id = ANY(p_order_ids); - - GET DIAGNOSTICS v_count = ROW_COUNT; - RETURN jsonb_build_object('success', true, 'count', v_count); -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/070_rls_policy_audit.sql b/supabase/migrations/.archived/070_rls_policy_audit.sql deleted file mode 100644 index 610dd72..0000000 --- a/supabase/migrations/.archived/070_rls_policy_audit.sql +++ /dev/null @@ -1,278 +0,0 @@ --- Migration 070: RLS Policy Audit Fixes --- 1. Add RLS to products (was completely missing) --- 2. Add brand scoping to stops RLS --- 3. Add deleted_at IS NULL to wholesale_customers and wholesale_products RLS --- 4. Add SELECT policy to order_items (brand-scoped via orders/stops JOIN) - -BEGIN; - --- ── 1. products — Add RLS ────────────────────────────────────────────────────── --- Table has brand_id column but no RLS policies whatsoever. --- Add SELECT for admins (brand-scoped), INSERT/UPDATE/DELETE blocked (RPC only). - -ALTER TABLE public.products ENABLE ROW LEVEL SECURITY; - --- Platform admin can read all products -CREATE POLICY "platform_admin_read_products" ON public.products - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - ) - ); - --- Brand admin can read their brand's products -CREATE POLICY "brand_admin_read_products" ON public.products - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'brand_admin' - AND au.brand_id = products.brand_id - ) - ); - --- Store employee can read their brand's products -CREATE POLICY "store_employee_read_products" ON public.products - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'store_employee' - AND au.brand_id = products.brand_id - ) - ); - --- wholesale_customer can read their brand's products (via brand_id join) -CREATE POLICY "wholesale_customer_read_products" ON public.products - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - JOIN wholesale_customers wc ON wc.user_id = au.user_id - WHERE au.user_id = auth.uid() - AND au.role = 'wholesale_customer' - AND wc.brand_id = products.brand_id - ) - ); - --- Block INSERT/UPDATE/DELETE on products — all writes go through RPCs --- No policy = no access for mutating operations (authenticated users blocked) -CREATE POLICY "block_products_mutations" ON public.products - FOR INSERT WITH CHECK (false); - -CREATE POLICY "block_products_update" ON public.products - FOR UPDATE USING (false); - -CREATE POLICY "block_products_delete" ON public.products - FOR DELETE USING (false); - --- ── 2. stops — Add brand scoping ─────────────────────────────────────────────── --- Current policy allows ANY admin to read ALL stops across all brands. --- Replace with brand-scoped policies. - -DROP POLICY IF EXISTS "Admins can read stops" ON stops; -ALTER TABLE stops DISABLE ROW LEVEL SECURITY; -ALTER TABLE stops ENABLE ROW LEVEL SECURITY; - --- Platform admin sees all stops -CREATE POLICY "platform_admin_read_stops" ON stops - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - ) - ); - --- Brand admin / store_employee sees only their brand's stops -CREATE POLICY "brand_admin_read_stops" ON stops - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role IN ('brand_admin', 'store_employee') - AND au.brand_id = stops.brand_id - ) - ); - --- Block all mutations on stops (admin only via RPC) -CREATE POLICY "block_stops_mutations" ON stops - FOR INSERT WITH CHECK (false); - -CREATE POLICY "block_stops_update" ON stops - FOR UPDATE USING (false); - -CREATE POLICY "block_stops_delete" ON stops - FOR DELETE USING (false); - --- ── 3. wholesale_customers — Add deleted_at IS NULL ─────────────────────────── --- Soft-deleted customers (deleted_at NOT NULL) should not be readable via REST. --- Split into two policies: one for platform_admin (no deleted filter), one for brand_admin. - -DROP POLICY IF EXISTS "brand_admin_manage_wholesale_customers" ON wholesale_customers; -DROP POLICY IF EXISTS "wholesale_customer_read_own" ON wholesale_customers; -ALTER TABLE wholesale_customers DISABLE ROW LEVEL SECURITY; -ALTER TABLE wholesale_customers ENABLE ROW LEVEL SECURITY; - --- Platform admin can read all customers (including soft-deleted for recovery) -CREATE POLICY "platform_admin_manage_wholesale_customers" ON wholesale_customers - FOR ALL USING ( - current_setting('app.settings.role', true)::TEXT = 'platform_admin' - ); - --- Brand admin can only manage their brand's non-deleted customers -CREATE POLICY "brand_admin_manage_wholesale_customers" ON wholesale_customers - FOR ALL USING ( - current_setting('app.settings.role', true)::TEXT = 'brand_admin' - AND brand_id = current_setting('app.settings.brand_id', true)::UUID - AND deleted_at IS NULL - ); - --- Customers can read their own record (not brand-admin, wholesale_customer role) -CREATE POLICY "wholesale_customer_read_own" ON wholesale_customers - FOR SELECT USING ( - current_setting('app.settings.role', true)::TEXT = 'wholesale_customer' - AND user_id = current_setting('app.settings.user_id', true)::UUID - AND deleted_at IS NULL - ); - --- ── 4. wholesale_products — Add deleted_at IS NULL ─────────────────────────── --- Soft-deleted products should not be readable via REST. - -DROP POLICY IF EXISTS "brand_admin_manage_wholesale_products" ON wholesale_products; -ALTER TABLE wholesale_products DISABLE ROW LEVEL SECURITY; -ALTER TABLE wholesale_products ENABLE ROW LEVEL SECURITY; - --- Platform admin can read all (including soft-deleted for recovery) -CREATE POLICY "platform_admin_manage_wholesale_products" ON wholesale_products - FOR ALL USING ( - current_setting('app.settings.role', true)::TEXT = 'platform_admin' - ); - --- Brand admin can only manage their brand's non-deleted products -CREATE POLICY "brand_admin_manage_wholesale_products" ON wholesale_products - FOR ALL USING ( - current_setting('app.settings.role', true)::TEXT = 'brand_admin' - AND brand_id = current_setting('app.settings.brand_id', true)::UUID - AND deleted_at IS NULL - ); - --- ── 5. order_items — Add brand-scoped SELECT policy ─────────────────────────── --- order_items currently has no RLS policies (all access blocked). --- Add SELECT with brand scoping via orders/stops JOIN. - -ALTER TABLE public.order_items ENABLE ROW LEVEL SECURITY; - --- Platform admin can read all order items -CREATE POLICY "platform_admin_read_order_items" ON order_items - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - ) - ); - --- Brand admin / store_employee: can read order items for their brand's orders -CREATE POLICY "brand_admin_read_order_items" ON order_items - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - JOIN orders o ON o.id = order_items.order_id - JOIN stops s ON s.id = o.stop_id - WHERE au.user_id = auth.uid() - AND au.role IN ('brand_admin', 'store_employee') - AND s.brand_id = au.brand_id - ) - ); - --- Block all mutations on order_items (admin only via RPC) -CREATE POLICY "block_order_items_mutations" ON order_items - FOR INSERT WITH CHECK (false); - -CREATE POLICY "block_order_items_update" ON order_items - FOR UPDATE USING (false); - -CREATE POLICY "block_order_items_delete" ON order_items - FOR DELETE USING (false); - --- ── 6. orders — Add brand scoping to write policies ─────────────────────────── --- Existing SELECT policies use stops JOIN correctly. --- Ensure INSERT/UPDATE/DELETE policies also enforce brand scoping (or block them). --- Note: orders INSERT/UPDATE/DELETE go through RPCs — add blocking policies as safety net. - -CREATE POLICY "block_orders_insert" ON orders - FOR INSERT WITH CHECK (false); - -CREATE POLICY "block_orders_update" ON orders - FOR UPDATE USING (false); - -CREATE POLICY "block_orders_delete" ON orders - FOR DELETE USING (false); - --- ── 7. wholesale_orders — Ensure no deleted_at issues ───────────────────────── --- wholesale_orders has no deleted_at column (hard delete). No changes needed. --- But add explicit INSERT/UPDATE/DELETE block to match other tables. --- All writes go through RPCs — block direct REST mutations. - -CREATE POLICY "block_wholesale_orders_insert" ON wholesale_orders - FOR INSERT WITH CHECK (false); - -CREATE POLICY "block_wholesale_orders_update" ON wholesale_orders - FOR UPDATE USING (false); - -CREATE POLICY "block_wholesale_orders_delete" ON wholesale_orders - FOR DELETE USING (false); - --- ── 8. wholesale_order_items — Block direct REST mutations ──────────────────── --- SELECT is already scoped in 069. Block INSERT/UPDATE/DELETE. - -CREATE POLICY "block_wholesale_order_items_insert" ON wholesale_order_items - FOR INSERT WITH CHECK (false); - -CREATE POLICY "block_wholesale_order_items_update" ON wholesale_order_items - FOR UPDATE USING (false); - -CREATE POLICY "block_wholesale_order_items_delete" ON wholesale_order_items - FOR DELETE USING (false); - --- ── 9. wholesale_deposits — Block direct REST mutations ─────────────────────── --- SELECT is already scoped in 069. Block INSERT/UPDATE/DELETE. - -CREATE POLICY "block_wholesale_deposits_insert" ON wholesale_deposits - FOR INSERT WITH CHECK (false); - -CREATE POLICY "block_wholesale_deposits_update" ON wholesale_deposits - FOR UPDATE USING (false); - -CREATE POLICY "block_wholesale_deposits_delete" ON wholesale_deposits - FOR DELETE USING (false); - --- ── 10. wholesale_customers — Block direct REST mutations ────────────────────── --- SELECT policies already set above. Block mutations. - -CREATE POLICY "block_wholesale_customers_insert" ON wholesale_customers - FOR INSERT WITH CHECK (false); - -CREATE POLICY "block_wholesale_customers_update" ON wholesale_customers - FOR UPDATE USING (false); - -CREATE POLICY "block_wholesale_customers_delete" ON wholesale_customers - FOR DELETE USING (false); - --- ── 11. wholesale_products — Block direct REST mutations ─────────────────────── --- SELECT policies already set above. Block mutations. - -CREATE POLICY "block_wholesale_products_insert" ON wholesale_products - FOR INSERT WITH CHECK (false); - -CREATE POLICY "block_wholesale_products_update" ON wholesale_products - FOR UPDATE USING (false); - -CREATE POLICY "block_wholesale_products_delete" ON wholesale_products - FOR DELETE USING (false); - -COMMIT; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/071_wholesale_square_sync_toggle.sql b/supabase/migrations/.archived/071_wholesale_square_sync_toggle.sql deleted file mode 100644 index fbf358c..0000000 --- a/supabase/migrations/.archived/071_wholesale_square_sync_toggle.sql +++ /dev/null @@ -1,180 +0,0 @@ --- Migration 071: Add square_sync_enabled to wholesale_settings --- - Adds square_sync_enabled BOOLEAN column (default false) --- - Updates upsert_wholesale_settings RPC to accept p_square_sync_enabled --- - Updates enqueue_square_sync to respect wholesale_settings.square_sync_enabled - -BEGIN; - --- ── 1. Add square_sync_enabled column ──────────────────────────────────────── -ALTER TABLE public.wholesale_settings - ADD COLUMN IF NOT EXISTS square_sync_enabled BOOLEAN NOT NULL DEFAULT false; - --- ── 2. Update enqueue_square_sync to check wholesale_settings ──────────────── --- Only enqueue if square_sync_enabled = true for the brand. --- If no wholesale_settings record exists, skip (auto-sync disabled until configured). -DROP FUNCTION IF EXISTS public.enqueue_square_sync(UUID, TEXT); -CREATE OR REPLACE FUNCTION public.enqueue_square_sync( - p_brand_id UUID, - p_sync_type TEXT DEFAULT 'products' -) -RETURNS void -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - -- Guard: only enqueue if square_sync_enabled for this brand - IF NOT EXISTS ( - SELECT 1 FROM wholesale_settings ws - WHERE ws.brand_id = p_brand_id - AND ws.square_sync_enabled = true - ) THEN - RETURN; -- sync disabled for this brand - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM square_sync_queue - WHERE brand_id = p_brand_id - AND sync_type = p_sync_type - AND status IN ('pending', 'processing') - ) THEN - INSERT INTO square_sync_queue (brand_id, sync_type, status) - VALUES (p_brand_id, p_sync_type, 'pending'); - END IF; -END; -$$; - --- ── 3. Update upsert_wholesale_settings RPC ─────────────────────────────────── --- Drop first to avoid "cannot remove parameter defaults" error. -DROP FUNCTION IF EXISTS public.upsert_wholesale_settings( - UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, JSONB, BOOLEAN -); -CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings( - p_brand_id UUID, - p_require_approval BOOLEAN DEFAULT NULL, - p_min_order_amount NUMERIC DEFAULT NULL, - p_online_payment_enabled BOOLEAN DEFAULT NULL, - p_wholesale_enabled BOOLEAN DEFAULT NULL, - p_pickup_location TEXT DEFAULT NULL, - p_fob_location TEXT DEFAULT NULL, - p_from_email TEXT DEFAULT NULL, - p_invoice_business_name TEXT DEFAULT NULL, - p_invoice_business_address TEXT DEFAULT NULL, - p_invoice_business_phone TEXT DEFAULT NULL, - p_invoice_business_email TEXT DEFAULT NULL, - p_invoice_business_website TEXT DEFAULT NULL, - p_notification_email TEXT DEFAULT NULL, - p_notification_recipients JSONB DEFAULT NULL, - p_square_sync_enabled BOOLEAN DEFAULT NULL -- NEW -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - INSERT INTO wholesale_settings ( - brand_id, require_approval, min_order_amount, online_payment_enabled, - wholesale_enabled, pickup_location, fob_location, from_email, - invoice_business_name, invoice_business_address, invoice_business_phone, - invoice_business_email, invoice_business_website, - notification_email, notification_recipients, square_sync_enabled - ) - VALUES ( - p_brand_id, COALESCE(p_require_approval, true), p_min_order_amount, - COALESCE(p_online_payment_enabled, false), COALESCE(p_wholesale_enabled, true), - p_pickup_location, p_fob_location, p_from_email, - p_invoice_business_name, p_invoice_business_address, p_invoice_business_phone, - p_invoice_business_email, p_invoice_business_website, - p_notification_email, COALESCE(p_notification_recipients, '[]'::JSONB), - COALESCE(p_square_sync_enabled, false) - ) - ON CONFLICT (brand_id) DO UPDATE SET - require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval), - min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount), - online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled), - wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled), - pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location), - fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location), - from_email = COALESCE(p_from_email, wholesale_settings.from_email), - invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name), - invoice_business_address = COALESCE(p_invoice_business_address, wholesale_settings.invoice_business_address), - invoice_business_phone = COALESCE(p_invoice_business_phone, wholesale_settings.invoice_business_phone), - invoice_business_email = COALESCE(p_invoice_business_email, wholesale_settings.invoice_business_email), - invoice_business_website = COALESCE(p_invoice_business_website, wholesale_settings.invoice_business_website), - notification_email = COALESCE(p_notification_email, wholesale_settings.notification_email), - notification_recipients = COALESCE(p_notification_recipients, wholesale_settings.notification_recipients), - square_sync_enabled = COALESCE(p_square_sync_enabled, wholesale_settings.square_sync_enabled), - updated_at = now() - RETURNING jsonb_build_object( - 'success', true, - 'brand_id', brand_id, - 'square_sync_enabled', square_sync_enabled - ) INTO v_result; - - RETURN v_result; -END; -$$; - --- ── 4. Update get_wholesale_settings to return square_sync_enabled ────────── -DROP FUNCTION IF EXISTS public.get_wholesale_settings(UUID); -CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_build_object( - 'id', ws.id, - 'brand_id', ws.brand_id, - 'portal_page_id', ws.portal_page_id, - 'price_sheet_page_id', ws.price_sheet_page_id, - 'require_approval', ws.require_approval, - 'min_order_amount', ws.min_order_amount, - 'online_payment_enabled', ws.online_payment_enabled, - 'wholesale_enabled', ws.wholesale_enabled, - 'pickup_location', ws.pickup_location, - 'fob_location', ws.fob_location, - 'from_email', ws.from_email, - 'invoice_business_name', ws.invoice_business_name, - 'invoice_business_address', ws.invoice_business_address, - 'invoice_business_phone', ws.invoice_business_phone, - 'invoice_business_email', ws.invoice_business_email, - 'invoice_business_website', ws.invoice_business_website, - 'notification_email', ws.notification_email, - 'notification_recipients', COALESCE(ws.notification_recipients, '[]'::JSONB), - 'square_sync_enabled', ws.square_sync_enabled, - 'last_invoice_number', ws.last_invoice_number - ) INTO v_result - FROM wholesale_settings ws - WHERE ws.brand_id = p_brand_id; - - -- Fallback: return safe defaults if no record exists yet - IF v_result IS NULL THEN - RETURN jsonb_build_object( - 'id', NULL, - 'brand_id', p_brand_id, - 'require_approval', true, - 'wholesale_enabled', true, - 'online_payment_enabled', false, - 'square_sync_enabled', false, - 'pickup_location', '', - 'fob_location', '', - 'from_email', '', - 'invoice_business_name', '', - 'invoice_business_address', NULL, - 'invoice_business_phone', NULL, - 'invoice_business_email', NULL, - 'invoice_business_website', NULL, - 'notification_email', NULL, - 'notification_recipients', '[]'::JSONB, - 'last_invoice_number', 0 - ); - END IF; - - RETURN v_result; -END; -$$; - -COMMIT; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/072_orders_brand_scoping.sql b/supabase/migrations/.archived/072_orders_brand_scoping.sql deleted file mode 100644 index 1f12adb..0000000 --- a/supabase/migrations/.archived/072_orders_brand_scoping.sql +++ /dev/null @@ -1,218 +0,0 @@ --- Migration 072: Brand Scoping for Retail Orders RPCs --- Ensures get_admin_orders and get_admin_order_detail enforce p_brand_id at the RPC layer. --- RLS policies on orders and order_items tables for brand scoping via stops. - --- Step 1: Drop and recreate get_admin_orders with proper brand scoping -DROP FUNCTION IF EXISTS public.get_admin_orders(UUID); - -CREATE OR REPLACE FUNCTION public.get_admin_orders(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_orders JSONB; - v_stops JSONB; -BEGIN - IF p_brand_id IS NULL THEN - -- platform_admin: return all orders - SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB) - INTO v_orders - FROM ( - SELECT o.id, o.customer_name, o.customer_email, o.customer_phone, - o.stop_id, o.status, o.subtotal, o.pickup_complete, - o.pickup_completed_at, o.pickup_completed_by, o.created_at, - o.brand_id, - CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) END as stops - FROM orders o LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.stop_id IS NOT NULL - LIMIT 500 - ) t; - - SELECT COALESCE(jsonb_agg(jsonb_build_object( - 'id', id, 'city', city, 'state', state, - 'date', date, 'time', time, 'location', location, 'brand_id', brand_id - ) ORDER BY date), '[]'::JSONB) - INTO v_stops FROM stops WHERE active = true; - ELSE - -- brand_admin or store_employee: restrict to brand via stops join - SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB) - INTO v_orders - FROM ( - SELECT o.id, o.customer_name, o.customer_email, o.customer_phone, - o.stop_id, o.status, o.subtotal, o.pickup_complete, - o.pickup_completed_at, o.pickup_completed_by, o.created_at, - o.brand_id, - jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) as stops - FROM orders o JOIN stops s ON o.stop_id = s.id - WHERE s.brand_id = p_brand_id - LIMIT 500 - ) t; - - SELECT COALESCE(jsonb_agg(jsonb_build_object( - 'id', id, 'city', city, 'state', state, - 'date', date, 'time', time, 'location', location, 'brand_id', brand_id - ) ORDER BY date), '[]'::JSONB) - INTO v_stops FROM stops WHERE active = true AND brand_id = p_brand_id; - END IF; - - RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops); -END; -$$; - --- Step 2: Drop and recreate get_admin_order_detail with proper brand scoping -DROP FUNCTION IF EXISTS public.get_admin_order_detail(UUID, UUID); - -CREATE OR REPLACE FUNCTION public.get_admin_order_detail(p_order_id UUID, p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_order JSONB; - v_order_brand_id UUID; - v_stop_brand_id UUID; -BEGIN - -- Resolve brand from order.brand_id or stop.brand_id - SELECT COALESCE(o.brand_id, s.brand_id), s.brand_id - INTO v_order_brand_id, v_stop_brand_id - FROM orders o LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.id = p_order_id; - - -- Enforce brand scoping - IF p_brand_id IS NOT NULL AND v_order_brand_id IS NOT NULL AND v_order_brand_id != p_brand_id THEN - RETURN jsonb_build_object('error', 'Order not found or access denied'); - END IF; - - -- No order found - IF v_order_brand_id IS NULL AND p_brand_id IS NOT NULL THEN - RETURN jsonb_build_object('error', 'Order not found or access denied'); - END IF; - - SELECT jsonb_build_object( - 'id', o.id, - 'customer_name', o.customer_name, - 'customer_email', o.customer_email, - 'customer_phone', o.customer_phone, - 'stop_id', o.stop_id, - 'status', o.status, - 'subtotal', o.subtotal, - 'pickup_complete', o.pickup_complete, - 'pickup_completed_at', o.pickup_completed_at, - 'pickup_completed_by', o.pickup_completed_by, - 'created_at', o.created_at, - 'discount_amount', o.discount_amount, - 'tax_amount', o.tax_amount, - 'tax_rate', o.tax_rate, - 'tax_location', o.tax_location, - 'discount_reason', o.discount_reason, - 'internal_notes', o.internal_notes, - 'payment_processor', o.payment_processor, - 'payment_status', o.payment_status, - 'payment_transaction_id', o.payment_transaction_id, - 'refunded_amount', o.refunded_amount, - 'refund_reason', o.refund_reason, - 'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) END, - 'order_items', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'id', oi.id, 'product_id', oi.product_id, 'product_name', p.name, - 'quantity', oi.quantity, 'price', oi.price, - 'products', jsonb_build_object('name', p.name) - )) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id - ), '[]'::JSONB), - 'refunds', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'id', r.id, 'order_id', r.order_id, 'amount', r.amount, - 'reason', r.reason, 'processor', r.processor, - 'processor_refund_id', r.processor_refund_id, - 'status', r.status, 'created_at', r.created_at - )) - FROM refunds r - WHERE r.order_id = o.id - ), '[]'::JSONB) - ) - INTO v_order - FROM orders o - LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.id = p_order_id; - - RETURN v_order; -END; -$$; - --- Step 3: RLS on orders table — add brand-scoped SELECT policies -ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY; - --- Drop existing permissive policy if present -DROP POLICY IF EXISTS "orders_select_all" ON public.orders; - --- platform_admin and brand_admin SELECT policies (stops join for brand scoping) -CREATE POLICY "orders_select_platform_admin" ON public.orders - FOR SELECT USING ( - auth.uid() IN ( - SELECT au.user_id FROM admin_users au WHERE au.role = 'platform_admin' - ) - ); - -CREATE POLICY "orders_select_brand_admin" ON public.orders - FOR SELECT USING ( - auth.uid() IN ( - SELECT au.user_id FROM admin_users au - WHERE au.role = 'brand_admin' - AND (SELECT brand_id FROM stops WHERE stops.id = orders.stop_id) = au.brand_id - ) - ); - -CREATE POLICY "orders_select_store_employee" ON public.orders - FOR SELECT USING ( - auth.uid() IN ( - SELECT au.user_id FROM admin_users au - WHERE au.role = 'store_employee' - AND (SELECT brand_id FROM stops WHERE stops.id = orders.stop_id) = au.brand_id - ) - ); - --- Block all mutations at RLS level — all writes go through RPCs -DROP POLICY IF EXISTS "orders_insert_all" ON public.orders; -CREATE POLICY "orders_insert_blocked" ON public.orders FOR INSERT WITH CHECK (false); -DROP POLICY IF EXISTS "orders_update_all" ON public.orders; -CREATE POLICY "orders_update_blocked" ON public.orders FOR UPDATE USING (false); -DROP POLICY IF EXISTS "orders_delete_all" ON public.orders; -CREATE POLICY "orders_delete_blocked" ON public.orders FOR DELETE USING (false); - --- Step 4: RLS on order_items table -ALTER TABLE public.order_items ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "order_items_select_all" ON public.order_items; -CREATE POLICY "order_items_select_platform_admin" ON public.order_items - FOR SELECT USING ( - auth.uid() IN (SELECT user_id FROM admin_users WHERE role = 'platform_admin') - OR - EXISTS ( - SELECT 1 FROM orders o - JOIN stops s ON o.stop_id = s.id - JOIN admin_users au ON s.brand_id = au.brand_id - WHERE o.id = order_items.order_id AND au.user_id = auth.uid() - ) - ); - -DROP POLICY IF EXISTS "order_items_insert_all" ON public.order_items; -CREATE POLICY "order_items_insert_blocked" ON public.order_items FOR INSERT WITH CHECK (false); -DROP POLICY IF EXISTS "order_items_update_all" ON public.order_items; -CREATE POLICY "order_items_update_blocked" ON public.order_items FOR UPDATE USING (false); -DROP POLICY IF EXISTS "order_items_delete_all" ON public.order_items; -CREATE POLICY "order_items_delete_blocked" ON public.order_items FOR DELETE USING (false); \ No newline at end of file diff --git a/supabase/migrations/.archived/073_pickup_order_items_rpc.sql b/supabase/migrations/.archived/073_pickup_order_items_rpc.sql deleted file mode 100644 index 9b2ab91..0000000 --- a/supabase/migrations/.archived/073_pickup_order_items_rpc.sql +++ /dev/null @@ -1,185 +0,0 @@ --- Migration 073: Add order_items to get_admin_orders for driver pickup filtering --- Adds fulfillment-aware order_items to the orders list RPC so the pickup --- portal can filter to pickup-only items and detect mixed-fulfillment orders. - --- Step 1: Drop and recreate get_admin_orders with order_items subquery -DROP FUNCTION IF EXISTS public.get_admin_orders(UUID); - -CREATE OR REPLACE FUNCTION public.get_admin_orders(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_orders JSONB; - v_stops JSONB; -BEGIN - IF p_brand_id IS NULL THEN - -- platform_admin: return all orders with order_items - SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB) - INTO v_orders - FROM ( - SELECT o.id, o.customer_name, o.customer_email, o.customer_phone, - o.stop_id, o.status, o.subtotal, o.pickup_complete, - o.pickup_completed_at, o.pickup_completed_by, o.created_at, - o.brand_id, - CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) END as stops, - COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'id', oi.id, - 'product_id', oi.product_id, - 'product_name', p.name, - 'quantity', oi.quantity, - 'price', oi.price, - 'fulfillment', oi.fulfillment, - 'products', jsonb_build_object('name', p.name) - ) ORDER BY p.name) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id - ), '[]'::JSONB) AS order_items - FROM orders o LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.stop_id IS NOT NULL - LIMIT 500 - ) t; - - SELECT COALESCE(jsonb_agg(jsonb_build_object( - 'id', id, 'city', city, 'state', state, - 'date', date, 'time', time, 'location', location, 'brand_id', brand_id - ) ORDER BY date), '[]'::JSONB) - INTO v_stops FROM stops WHERE active = true; - ELSE - -- brand-scoped: restrict to brand via stops join, include order_items - SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB) - INTO v_orders - FROM ( - SELECT o.id, o.customer_name, o.customer_email, o.customer_phone, - o.stop_id, o.status, o.subtotal, o.pickup_complete, - o.pickup_completed_at, o.pickup_completed_by, o.created_at, - o.brand_id, - jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) as stops, - COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'id', oi.id, - 'product_id', oi.product_id, - 'product_name', p.name, - 'quantity', oi.quantity, - 'price', oi.price, - 'fulfillment', oi.fulfillment, - 'products', jsonb_build_object('name', p.name) - ) ORDER BY p.name) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id - ), '[]'::JSONB) AS order_items - FROM orders o JOIN stops s ON o.stop_id = s.id - WHERE s.brand_id = p_brand_id - LIMIT 500 - ) t; - - SELECT COALESCE(jsonb_agg(jsonb_build_object( - 'id', id, 'city', city, 'state', state, - 'date', date, 'time', time, 'location', location, 'brand_id', brand_id - ) ORDER BY date), '[]'::JSONB) - INTO v_stops FROM stops WHERE active = true AND brand_id = p_brand_id; - END IF; - - RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops); -END; -$$; - --- Step 2: Also update get_admin_order_detail to use consistent order_items shape --- (it already has fulfillment, but ensure the shape matches what DriverPickupPanel expects) -DROP FUNCTION IF EXISTS public.get_admin_order_detail(UUID, UUID); - -CREATE OR REPLACE FUNCTION public.get_admin_order_detail(p_order_id UUID, p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_order JSONB; - v_order_brand_id UUID; - v_stop_brand_id UUID; -BEGIN - SELECT COALESCE(o.brand_id, s.brand_id), s.brand_id - INTO v_order_brand_id, v_stop_brand_id - FROM orders o LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.id = p_order_id; - - IF p_brand_id IS NOT NULL AND v_order_brand_id IS NOT NULL AND v_order_brand_id != p_brand_id THEN - RETURN jsonb_build_object('error', 'Order not found or access denied'); - END IF; - - IF v_order_brand_id IS NULL AND p_brand_id IS NOT NULL THEN - RETURN jsonb_build_object('error', 'Order not found or access denied'); - END IF; - - SELECT jsonb_build_object( - 'id', o.id, - 'customer_name', o.customer_name, - 'customer_email', o.customer_email, - 'customer_phone', o.customer_phone, - 'stop_id', o.stop_id, - 'status', o.status, - 'subtotal', o.subtotal, - 'pickup_complete', o.pickup_complete, - 'pickup_completed_at', o.pickup_completed_at, - 'pickup_completed_by', o.pickup_completed_by, - 'created_at', o.created_at, - 'discount_amount', o.discount_amount, - 'tax_amount', o.tax_amount, - 'tax_rate', o.tax_rate, - 'tax_location', o.tax_location, - 'discount_reason', o.discount_reason, - 'internal_notes', o.internal_notes, - 'payment_processor', o.payment_processor, - 'payment_status', o.payment_status, - 'payment_transaction_id', o.payment_transaction_id, - 'refunded_amount', o.refunded_amount, - 'refund_reason', o.refund_reason, - 'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) END, - 'order_items', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'id', oi.id, - 'product_id', oi.product_id, - 'product_name', p.name, - 'quantity', oi.quantity, - 'price', oi.price, - 'fulfillment', oi.fulfillment, - 'products', jsonb_build_object('name', p.name) - ) ORDER BY p.name) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id - ), '[]'::JSONB), - 'refunds', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'id', r.id, 'order_id', r.order_id, 'amount', r.amount, - 'reason', r.reason, 'processor', r.processor, - 'processor_refund_id', r.processor_refund_id, - 'status', r.status, 'created_at', r.created_at - )) - FROM refunds r - WHERE r.order_id = o.id - ), '[]'::JSONB) - ) - INTO v_order - FROM orders o - LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.id = p_order_id; - - RETURN v_order; -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/074_products_soft_delete.sql b/supabase/migrations/.archived/074_products_soft_delete.sql deleted file mode 100644 index e1d372c..0000000 --- a/supabase/migrations/.archived/074_products_soft_delete.sql +++ /dev/null @@ -1,77 +0,0 @@ --- Migration 074: Products Soft Delete --- Adds deleted_at column, delete_product RPC with order_items guard. --- Mirrors wholesale_products pattern (migration 061). - --- Step 1: Add deleted_at column -ALTER TABLE public.products -ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ DEFAULT NULL; - --- Step 2: Create delete_product RPC -CREATE OR REPLACE FUNCTION public.delete_product(p_product_id UUID, p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_product RECORD; - v_usage_count INT; - v_brand_id UUID; -BEGIN - -- Fetch product and lock it - SELECT id, brand_id INTO v_product - FROM products - WHERE id = p_product_id - FOR UPDATE; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - v_brand_id := v_product.brand_id; - - -- Brand scoping: if p_brand_id provided, enforce it - IF p_brand_id IS NOT NULL AND v_brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Check for usage in order_items - SELECT COUNT(*) INTO v_usage_count - FROM order_items - WHERE product_id = p_product_id; - - IF v_usage_count > 0 THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Cannot delete — product is attached to ' || v_usage_count || ' order(s). Set availability to inactive instead.' - ); - END IF; - - -- Soft delete - UPDATE products SET deleted_at = now() WHERE id = p_product_id; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- Step 3: Add RLS policy for deleted_at filtering on SELECT --- The existing SELECT policies in 070 will now also need deleted_at filtering --- for brand_admin and store_employee. We add a new policy that enforces deleted_at IS NULL. - --- First drop the existing brand-scoped SELECT policies (they don't filter deleted_at yet) -DROP POLICY IF EXISTS "brand_admin_read_products" ON public.products; -DROP POLICY IF EXISTS "store_employee_read_products" ON public.products; - --- Brand admin: own brand + not deleted -CREATE POLICY "brand_admin_read_products" ON public.products - FOR SELECT USING ( - brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid() AND role = 'brand_admin' LIMIT 1) - AND deleted_at IS NULL - ); - --- Store employee: own brand + not deleted -CREATE POLICY "store_employee_read_products" ON public.products - FOR SELECT USING ( - brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid() AND role = 'store_employee' LIMIT 1) - AND deleted_at IS NULL - ); \ No newline at end of file diff --git a/supabase/migrations/.archived/075_stops_soft_delete.sql b/supabase/migrations/.archived/075_stops_soft_delete.sql deleted file mode 100644 index 8165b51..0000000 --- a/supabase/migrations/.archived/075_stops_soft_delete.sql +++ /dev/null @@ -1,82 +0,0 @@ --- Migration 075: Soft-delete for stops + brand scoping enforcement --- 1. Add deleted_at to stops --- 2. Create delete_stop RPC with order guard --- 3. Update RLS to filter deleted_at IS NULL - --- ── 1. Add deleted_at column ─────────────────────────────────────────────────── -ALTER TABLE stops ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ DEFAULT NULL; - --- ── 2. delete_stop RPC ──────────────────────────────────────────────────────── -CREATE OR REPLACE FUNCTION public.delete_stop(p_stop_id UUID, p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop RECORD; - v_active_order_count INT; - v_result JSONB; -BEGIN - -- Lock the stop row for update - SELECT * INTO v_stop FROM stops WHERE id = p_stop_id FOR UPDATE; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Brand scoping: brand_admin can only delete their own stops - IF p_brand_id IS NOT NULL AND v_stop.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - ELSIF p_brand_id IS NULL AND v_stop.brand_id IS NOT NULL THEN - -- Caller didn't pass brand_id but stop has one — guard against platform admin accident - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Guard: check for active or future orders at this stop - SELECT COUNT(*) INTO v_active_order_count - FROM orders o - JOIN stops s ON o.stop_id = s.id - WHERE s.id = p_stop_id - AND o.pickup_complete = false - AND o.deleted_at IS NULL; - - IF v_active_order_count > 0 THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Cannot delete stop — it has ' || v_active_order_count || ' active order(s). Mark orders as picked up first.' - ); - END IF; - - -- Soft delete - UPDATE stops SET deleted_at = NOW() WHERE id = p_stop_id; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── 3. Update RLS — add deleted_at IS NULL to brand_admin read policy ────────── --- drops are re-created in the same block to avoid breaking the audit SQL -DROP POLICY IF EXISTS "brand_admin_read_stops" ON stops; -DROP POLICY IF EXISTS "platform_admin_read_stops" ON stops; - --- Platform admin sees all non-deleted stops -CREATE POLICY "platform_admin_read_stops" ON stops - FOR SELECT USING ( - deleted_at IS NULL - AND EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - ) - ); - --- Brand admin / store_employee sees only their brand's non-deleted stops -CREATE POLICY "brand_admin_read_stops" ON stops - FOR SELECT USING ( - deleted_at IS NULL - AND EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role IN ('brand_admin', 'store_employee') - AND au.brand_id = stops.brand_id - ) - ); diff --git a/supabase/migrations/.archived/076_stops_address_cutoff.sql b/supabase/migrations/.archived/076_stops_address_cutoff.sql deleted file mode 100644 index f1fa10f..0000000 --- a/supabase/migrations/.archived/076_stops_address_cutoff.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Migration 076: Add address, zip, cutoff_time to stops --- Supports driver navigation (street address) and customer cutoff deadline - -ALTER TABLE stops ADD COLUMN IF NOT EXISTS address TEXT DEFAULT NULL; -ALTER TABLE stops ADD COLUMN IF NOT EXISTS zip TEXT DEFAULT NULL; -ALTER TABLE stops ADD COLUMN IF NOT EXISTS cutoff_time TIMESTAMPTZ DEFAULT NULL; diff --git a/supabase/migrations/.archived/077_stops_status.sql b/supabase/migrations/.archived/077_stops_status.sql deleted file mode 100644 index bf8caeb..0000000 --- a/supabase/migrations/.archived/077_stops_status.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Migration 077: Add status column to stops --- 'draft' = imported, awaiting review | 'active' = published - -ALTER TABLE stops ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active' NOT NULL; - --- Set existing stops to 'active' -UPDATE stops SET status = 'active' WHERE status IS NULL; - -ALTER TABLE stops ALTER COLUMN status SET NOT NULL; diff --git a/supabase/migrations/.archived/078_wholesale_deposit_guard.sql b/supabase/migrations/.archived/078_wholesale_deposit_guard.sql deleted file mode 100644 index 6996403..0000000 --- a/supabase/migrations/.archived/078_wholesale_deposit_guard.sql +++ /dev/null @@ -1,63 +0,0 @@ --- Migration 078: Over-deposit guard on wholesale deposits --- Prevents recording a deposit that exceeds the remaining balance_due - -CREATE OR REPLACE FUNCTION public.record_wholesale_deposit( - p_order_id UUID DEFAULT NULL, - p_amount NUMERIC DEFAULT NULL, - p_method TEXT DEFAULT 'cash', - p_reference TEXT DEFAULT NULL, - p_recorded_by UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_new_paid NUMERIC(10,2); - v_balance_due NUMERIC(10,2); -BEGIN - -- Guard: amount must be positive - IF p_amount IS NULL OR p_amount <= 0 THEN - RETURN jsonb_build_object('success', false, 'error', 'Deposit amount must be greater than zero.'); - END IF; - - -- Guard: cannot exceed remaining balance_due - SELECT balance_due INTO v_balance_due - FROM wholesale_orders - WHERE id = p_order_id; - - IF v_balance_due IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Order not found.'); - END IF; - - IF p_amount > v_balance_due THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Deposit of $' || p_amount::TEXT || ' exceeds the remaining balance of $' || v_balance_due::TEXT || '.' - ); - END IF; - - -- Compute new deposit_paid - SELECT deposit_paid + p_amount INTO v_new_paid - FROM wholesale_orders - WHERE id = p_order_id; - - UPDATE wholesale_orders - SET - deposit_paid = v_new_paid, - balance_due = subtotal - v_new_paid, - updated_at = now() - WHERE id = p_order_id; - - INSERT INTO wholesale_deposits (wholesale_order_id, amount, payment_method, reference, recorded_by) - VALUES (p_order_id, p_amount, p_method, p_reference, p_recorded_by); - - -- Advance to pending once deposit covers requirement - UPDATE wholesale_orders - SET status = 'pending' - WHERE id = p_order_id - AND v_new_paid >= deposit_required - AND status = 'awaiting_deposit'; - - RETURN jsonb_build_object('success', true); -END; -$$; diff --git a/supabase/migrations/.archived/079_resend_analytics.sql b/supabase/migrations/.archived/079_resend_analytics.sql deleted file mode 100644 index 1eaee42..0000000 --- a/supabase/migrations/.archived/079_resend_analytics.sql +++ /dev/null @@ -1,22 +0,0 @@ --- Migration 079: Add delivery/open/click analytics columns to communication_message_logs --- Enables Resend webhook handler to update email engagement metrics - -ALTER TABLE communication_message_logs - ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS opened_at TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS clicked_at TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS bounced_at TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS bounce_reason TEXT; - --- Index for efficient delivery status queries -CREATE INDEX IF NOT EXISTS idx_message_logs_delivered_at - ON communication_message_logs (delivered_at) - WHERE delivered_at IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_message_logs_opened_at - ON communication_message_logs (opened_at) - WHERE opened_at IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_message_logs_clicked_at - ON communication_message_logs (clicked_at) - WHERE clicked_at IS NOT NULL; \ No newline at end of file diff --git a/supabase/migrations/.archived/080_communication_segments.sql b/supabase/migrations/.archived/080_communication_segments.sql deleted file mode 100644 index 9ad73ee..0000000 --- a/supabase/migrations/.archived/080_communication_segments.sql +++ /dev/null @@ -1,89 +0,0 @@ --- Migration 080: communication_segments table for reusable audience segments --- Replaces ad-hoc JSONB audience_rules with named, saved segments - -CREATE TABLE communication_segments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - brand_id UUID REFERENCES brands NOT NULL, - name TEXT NOT NULL, - description TEXT, - rules JSONB NOT NULL DEFAULT '{}', - -- rules shape mirrors AudienceRules: - -- { target, stop_id, date_from, date_to, zip_codes, city, - -- order_history, days_back, product_id, customer_ids } - created_by UUID, - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now() -); - --- Brand scoping is enforced at the application layer (server actions). --- The SECURITY DEFINER RPCs bypass RLS, so no policies are needed. --- If RLS is needed later, add permissive policies here. -ALTER TABLE communication_segments DISABLE ROW LEVEL SECURITY; - --- RPCs for segments CRUD -CREATE OR REPLACE FUNCTION get_communication_segments(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN jsonb_build_object( - 'segments', ( - SELECT COALESCE(jsonb_agg(s.*), '[]'::jsonb) - FROM ( - SELECT id, brand_id, name, description, rules, - created_by, created_at, updated_at - FROM communication_segments - WHERE brand_id = p_brand_id - ORDER BY name - ) s - ) - ); -END; -$$; - -CREATE OR REPLACE FUNCTION upsert_communication_segment( - p_brand_id UUID, - p_name TEXT, - p_description TEXT DEFAULT NULL, - p_rules JSONB DEFAULT '{}', - p_created_by UUID DEFAULT NULL, - p_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - INSERT INTO communication_segments (id, brand_id, name, description, rules, created_by, updated_at) - VALUES ( - COALESCE(p_id, gen_random_uuid()), - p_brand_id, - p_name, - p_description, - p_rules, - p_created_by, - now() - ) - ON CONFLICT (id) DO UPDATE SET - name = EXCLUDED.name, - description = EXCLUDED.description, - rules = EXCLUDED.rules, - updated_at = now() - RETURNING to_jsonb(communication_segments.*) INTO v_result; - - RETURN v_result; -END; -$$; - -CREATE OR REPLACE FUNCTION delete_communication_segment(p_segment_id UUID, p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - DELETE FROM communication_segments - WHERE id = p_segment_id AND brand_id = p_brand_id; - - RETURN jsonb_build_object('success', true); -END; -$$; diff --git a/supabase/migrations/.archived/081_stop_blast_campaign.sql b/supabase/migrations/.archived/081_stop_blast_campaign.sql deleted file mode 100644 index bd0ec8b..0000000 --- a/supabase/migrations/.archived/081_stop_blast_campaign.sql +++ /dev/null @@ -1,135 +0,0 @@ --- Migration 081: Stop blast via communication_campaigns --- Routes stop-based blast messages through the campaign system for analytics --- and to respect contact opt-in/opt-out preferences. - -CREATE OR REPLACE FUNCTION send_stop_blast( - p_stop_id UUID, - p_brand_id UUID, - p_body TEXT, - p_channel TEXT DEFAULT 'email', -- 'sms', 'email', 'both' - p_subject TEXT DEFAULT NULL, - p_audience TEXT DEFAULT 'pending', -- 'all', 'pending', 'picked_up' - p_created_by UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_campaign_id UUID; - v_count INT := 0; - v_order RECORD; - v_contact JSONB; - v_customer_id UUID; - v_opted_in BOOLEAN; - v_email TEXT; - v_phone TEXT; - v_brand_name TEXT := 'Route Commerce'; - v_subj TEXT; - v_body_sub TEXT; -BEGIN - -- Resolve brand name for {{company_name}} substitution - SELECT b.name INTO v_brand_name - FROM brands b - WHERE b.id = p_brand_id; - IF v_brand_name IS NULL THEN - SELECT invoice_business_name INTO v_brand_name - FROM wholesale_settings - WHERE brand_id = p_brand_id - LIMIT 1; - END IF; - IF v_brand_name IS NULL THEN - v_brand_name := 'Route Commerce'; - END IF; - - v_subj := replace(replace(p_subject, '{{company_name}}', v_brand_name), '{{brand_name}}', v_brand_name); - v_body_sub := replace(replace(p_body, '{{company_name}}', v_brand_name), '{{brand_name}}', v_brand_name); - - -- Create campaign record - INSERT INTO communication_campaigns ( - brand_id, name, subject, body_text, - campaign_type, status, audience_rules, - sent_at, created_by - ) VALUES ( - p_brand_id, - 'Stop blast: ' || COALESCE(p_subject, p_body), - v_subj, - v_body_sub, - 'operational', - 'sent', - jsonb_build_object( - 'target', 'stop', - 'stop_id', p_stop_id, - 'date_from', NULL, - 'date_to', NULL - ), - now(), - p_created_by - ) RETURNING id INTO v_campaign_id; - - -- Fetch matching orders - FOR v_order IN - SELECT o.id, o.customer_id, o.customer_name, - o.customer_email, o.customer_phone, o.pickup_complete, - c.email_opt_in, c.sms_opt_in, c.unsubscribed_at - FROM orders o - LEFT JOIN communication_contacts c ON c.id = o.customer_id - WHERE o.stop_id = p_stop_id - AND ( - p_audience = 'all' - OR (p_audience = 'pending' AND NOT o.pickup_complete) - OR (p_audience = 'picked_up' AND o.pickup_complete) - ) - LOOP - -- Check opt-in status per channel - -- Default to opted-in for email (email_opt_in defaults TRUE in schema) - -- Default to opted-OUT for SMS (sms_opt_in defaults FALSE in schema) - v_opted_in := ( - CASE p_channel - WHEN 'sms' THEN v_order.customer_phone IS NOT NULL - AND COALESCE(v_order.sms_opt_in, FALSE) = TRUE - AND v_order.unsubscribed_at IS NULL - WHEN 'email' THEN v_order.customer_email IS NOT NULL - AND COALESCE(v_order.email_opt_in, TRUE) = TRUE - AND v_order.unsubscribed_at IS NULL - ELSE v_order.customer_email IS NOT NULL - AND COALESCE(v_order.email_opt_in, TRUE) = TRUE - AND v_order.unsubscribed_at IS NULL - END - ); - - IF NOT v_opted_in THEN - CONTINUE; - END IF; - - -- Log email message - IF p_channel IN ('email', 'both') AND v_order.customer_email IS NOT NULL THEN - INSERT INTO communication_message_logs ( - brand_id, campaign_id, customer_id, customer_email, - delivery_method, subject, body_preview, status, sent_at - ) VALUES ( - p_brand_id, v_campaign_id, v_order.customer_id, v_order.customer_email, - 'email', v_subj, LEFT(v_body_sub, 200), 'queued', now() - ); - v_count := v_count + 1; - END IF; - - -- Log SMS message - IF p_channel IN ('sms', 'both') AND v_order.customer_phone IS NOT NULL THEN - INSERT INTO communication_message_logs ( - brand_id, campaign_id, customer_id, customer_phone, - delivery_method, body_preview, status, sent_at - ) VALUES ( - p_brand_id, v_campaign_id, v_order.customer_id, v_order.customer_phone, - 'sms', LEFT(v_body_sub, 160), 'queued', now() - ); - v_count := v_count + 1; - END IF; - END LOOP; - - RETURN jsonb_build_object( - 'success', true, - 'campaign_id', v_campaign_id, - 'messages_logged', v_count - ); -END; -$$; diff --git a/supabase/migrations/.archived/082_brand_name_in_campaign_email.sql b/supabase/migrations/.archived/082_brand_name_in_campaign_email.sql deleted file mode 100644 index bd747b6..0000000 --- a/supabase/migrations/.archived/082_brand_name_in_campaign_email.sql +++ /dev/null @@ -1,74 +0,0 @@ --- Migration 082: Substitute {{company_name}} with brand name in campaign emails --- Updates send_campaign to look up brands.name and replace {{company_name}} --- in subject and body_text before queuing messages. - -CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID) -RETURNS jsonb -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_campaign communication_campaigns; - v_settings communication_settings; - v_brand RECORD; - v_audience JSONB; - v_entries JSONB := '[]'::JSONB; - v_customer JSONB; - v_count INTEGER := 0; - v_brand_name TEXT := 'Route Commerce'; -BEGIN - SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Campaign not found'); - END IF; - - -- Look up brand name for {{company_name}} substitution - SELECT b.name INTO v_brand_name - FROM brands b - WHERE b.id = v_campaign.brand_id; - -- Fall back to wholesale_settings if brand name not found - IF v_brand_name IS NULL THEN - SELECT invoice_business_name INTO v_brand_name - FROM wholesale_settings - WHERE brand_id = v_campaign.brand_id - LIMIT 1; - END IF; - IF v_brand_name IS NULL THEN - v_brand_name := 'Route Commerce'; - END IF; - - SELECT * INTO v_settings - FROM communication_settings - WHERE brand_id = v_campaign.brand_id; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand'); - END IF; - - v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules); - - FOR v_customer IN - SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb)) - LOOP - v_entries := v_entries || jsonb_build_array(jsonb_build_object( - 'brand_id', v_campaign.brand_id, - 'campaign_id', p_campaign_id, - 'customer_id', v_customer->>'id', - 'customer_email', v_customer->>'email', - 'delivery_method', 'email', - 'subject', - -- Substitute {{company_name}} in subject - replace(replace(v_campaign.subject, '{{company_name}}', v_brand_name), '{{brand_name}}', v_brand_name), - 'body_preview', left(replace(replace(v_campaign.body_text, '{{company_name}}', v_brand_name), '{{brand_name}}', v_brand_name), 500), - 'status', 'queued' - )); - v_count := v_count + 1; - END LOOP; - - PERFORM log_communication_messages(v_entries); - - UPDATE communication_campaigns - SET status = 'sent', sent_at = now(), updated_at = now() - WHERE id = p_campaign_id; - - RETURN jsonb_build_object('success', true, 'messages_logged', v_count); -END; -$$; diff --git a/supabase/migrations/.archived/083_shipping_settings.sql b/supabase/migrations/.archived/083_shipping_settings.sql deleted file mode 100644 index 6b94be4..0000000 --- a/supabase/migrations/.archived/083_shipping_settings.sql +++ /dev/null @@ -1,99 +0,0 @@ --- Migration 083: Shipping Settings + FedEx Credentials + Perishable Flag on Products --- Stores FedEx API credentials and perishable handling preferences per brand. - -CREATE TABLE IF NOT EXISTS public.shipping_settings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - brand_id UUID REFERENCES brands ON DELETE CASCADE, - carrier TEXT NOT NULL DEFAULT 'fedex', - - -- FedEx credentials - fedex_account_number TEXT, - fedex_api_key TEXT, - fedex_api_secret TEXT, - fedex_use_production BOOLEAN NOT NULL DEFAULT FALSE, - - -- Default service type for non-perishable orders - default_service_type TEXT NOT NULL DEFAULT 'FEDEX_GROUND', - - -- Handling instructions for perishable goods (sweet corn, onions, etc.) - refrigerated_handling_notes TEXT, - fragile_handling_notes TEXT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- One settings row per brand -CREATE UNIQUE INDEX IF NOT EXISTS shipping_settings_brand_idx - ON public.shipping_settings (brand_id) - WHERE brand_id IS NOT NULL; - -ALTER TABLE public.shipping_settings ENABLE ROW LEVEL SECURITY; - --- Brand admins manage their own settings; platform admins manage all -CREATE POLICY brand_admin_manage_shipping_settings ON public.shipping_settings - FOR ALL - USING ( - brand_id IS NOT NULL - AND auth.uid() IN ( - SELECT user_id FROM admin_users - WHERE brand_id = shipping_settings.brand_id - AND role IN ('brand_admin', 'platform_admin') - ) - ); - --- Platform admin global settings row (brand_id = NULL) -CREATE POLICY platform_admin_manage_global_shipping_settings ON public.shipping_settings - FOR ALL - USING ( - brand_id IS NULL - AND auth.uid() IN ( - SELECT user_id FROM admin_users - WHERE role = 'platform_admin' - ) - ); - --- ── Add is_perishable to products ───────────────────────────────────────── --- Flags a product as perishable (sweet corn, onions, etc.) --- Perishable orders can ONLY ship via OVERNIGHT or 2DAY air. - -ALTER TABLE public.products - ADD COLUMN IF NOT EXISTS is_perishable BOOLEAN NOT NULL DEFAULT FALSE; - -COMMENT ON COLUMN products.is_perishable IS - 'If true, this product can only ship via FEDEX_OVERNIGHT or FEDEX_2_DAY_AIR. Affects FedEx rate filtering.'; - --- ── RPC: get_order_items_perishable ──────────────────────────────────────── --- Returns true if ALL ship-able items in an order are perishable. --- Used by FedEx rate filtering logic. - -CREATE OR REPLACE FUNCTION public.get_order_items_perishable(p_order_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; - v_has_shipping_items BOOLEAN; - v_all_perishable BOOLEAN; -BEGIN - SELECT EXISTS ( - SELECT 1 FROM order_items oi - JOIN products p ON p.id = oi.product_id - WHERE oi.order_id = p_order_id AND oi.fulfillment = 'ship' - ) INTO v_has_shipping_items; - - IF NOT v_has_shipping_items THEN - RETURN jsonb_build_object('is_perishable', false); - END IF; - - SELECT NOT EXISTS ( - SELECT 1 FROM order_items oi - JOIN products p ON p.id = oi.product_id - WHERE oi.order_id = p_order_id - AND oi.fulfillment = 'ship' - AND COALESCE(p.is_perishable, false) = FALSE - ) INTO v_all_perishable; - - RETURN jsonb_build_object('is_perishable', v_all_perishable); -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/083_stripe_connect.sql b/supabase/migrations/.archived/083_stripe_connect.sql deleted file mode 100644 index bc2d06b..0000000 --- a/supabase/migrations/.archived/083_stripe_connect.sql +++ /dev/null @@ -1,140 +0,0 @@ --- Migration 083: Add stripe_user_id support to payment_settings for Stripe Connect --- This enables Stripe Connect Express account onboarding - --- First, add stripe_user_id column to payment_settings table if it doesn't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'payment_settings' AND column_name = 'stripe_user_id') THEN - ALTER TABLE payment_settings ADD COLUMN stripe_user_id TEXT; - END IF; -END $$; - --- Update upsert_payment_settings to support stripe_user_id -CREATE OR REPLACE FUNCTION public.upsert_payment_settings( - p_brand_id UUID, - p_provider TEXT, - p_stripe_publishable_key TEXT DEFAULT NULL, - p_stripe_secret_key TEXT DEFAULT NULL, - p_stripe_user_id TEXT DEFAULT NULL, - p_square_access_token TEXT DEFAULT NULL, - p_square_location_id TEXT DEFAULT NULL, - p_square_sync_enabled BOOLEAN DEFAULT NULL, - p_square_inventory_mode TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO payment_settings ( - brand_id, provider, - stripe_publishable_key, stripe_secret_key, stripe_user_id, - square_access_token, square_location_id, - square_sync_enabled, square_inventory_mode - ) - VALUES ( - p_brand_id, p_provider, - p_stripe_publishable_key, p_stripe_secret_key, p_stripe_user_id, - p_square_access_token, p_square_location_id, - COALESCE(p_square_sync_enabled, false), - COALESCE(p_square_inventory_mode, 'none') - ) - ON CONFLICT (brand_id) DO UPDATE SET - provider = COALESCE(p_provider, payment_settings.provider), - stripe_publishable_key = COALESCE(p_stripe_publishable_key, payment_settings.stripe_publishable_key), - stripe_secret_key = COALESCE(p_stripe_secret_key, payment_settings.stripe_secret_key), - stripe_user_id = COALESCE(p_stripe_user_id, payment_settings.stripe_user_id), - square_access_token = COALESCE(p_square_access_token, payment_settings.square_access_token), - square_location_id = COALESCE(p_square_location_id, payment_settings.square_location_id), - square_sync_enabled = COALESCE(p_square_sync_enabled, payment_settings.square_sync_enabled), - square_inventory_mode = COALESCE(p_square_inventory_mode, payment_settings.square_inventory_mode), - updated_at = now() - RETURNING jsonb_build_object( - 'id', id, - 'brand_id', brand_id, - 'provider', provider, - 'stripe_user_id', stripe_user_id, - 'stripe_publishable_key', stripe_publishable_key, - 'square_sync_enabled', square_sync_enabled, - 'square_inventory_mode', square_inventory_mode, - 'square_last_sync_at', square_last_sync_at, - 'square_last_sync_error', square_last_sync_error - ); -END; -$$; - --- Create function to get brand payment settings -CREATE OR REPLACE FUNCTION public.get_payment_settings( - p_brand_id UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object( - 'id', id, - 'brand_id', brand_id, - 'provider', provider, - 'stripe_publishable_key', stripe_publishable_key, - 'stripe_secret_key', stripe_secret_key, - 'stripe_user_id', stripe_user_id, - 'square_access_token', square_access_token, - 'square_location_id', square_location_id, - 'square_sync_enabled', square_sync_enabled, - 'square_inventory_mode', square_inventory_mode, - 'square_last_sync_at', square_last_sync_at, - 'square_last_sync_error', square_last_sync_error, - 'updated_at', updated_at - ) - FROM payment_settings - WHERE brand_id = p_brand_id - ); -END; -$$; - --- Create function to set Stripe Connect account -CREATE OR REPLACE FUNCTION public.set_stripe_connect_account( - p_brand_id UUID, - p_stripe_user_id TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO payment_settings (brand_id, provider, stripe_user_id) - VALUES (p_brand_id, 'stripe', p_stripe_user_id) - ON CONFLICT (brand_id) DO UPDATE SET - stripe_user_id = p_stripe_user_id, - provider = 'stripe', - updated_at = now() - RETURNING jsonb_build_object( - 'id', id, - 'brand_id', brand_id, - 'stripe_user_id', stripe_user_id, - 'provider', provider - ); -END; -$$; - --- Create function to disconnect Stripe Connect -CREATE OR REPLACE FUNCTION public.disconnect_stripe_connect( - p_brand_id UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE payment_settings - SET - stripe_user_id = NULL, - stripe_publishable_key = NULL, - stripe_secret_key = NULL, - provider = 'manual', - updated_at = now() - WHERE brand_id = p_brand_id; - - RETURN jsonb_build_object('success', true, 'brand_id', p_brand_id); -END; -$$; - -NOTIFY pgrst, 'reload schema'; \ No newline at end of file diff --git a/supabase/migrations/.archived/084_shipments.sql b/supabase/migrations/.archived/084_shipments.sql deleted file mode 100644 index 01a4481..0000000 --- a/supabase/migrations/.archived/084_shipments.sql +++ /dev/null @@ -1,73 +0,0 @@ --- Migration 084: FedEx Shipments Table --- Stores created FedEx shipment records with label URL, tracking, and special services. - -CREATE TABLE IF NOT EXISTS public.shipments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - order_id UUID NOT NULL REFERENCES orders ON DELETE CASCADE, - - -- Carrier details - carrier TEXT NOT NULL DEFAULT 'fedex', - service_type TEXT NOT NULL, -- FEDEX_OVERNIGHT, FEDEX_2_DAY_AIR, FEDEX_GROUND, etc. - tracking_number TEXT, - label_url TEXT, -- FedEx label PDF URL - rate_charged NUMERIC(10, 2), -- Actual rate charged by FedEx (cents) - - -- Delivery estimates - estimated_delivery_date DATE, - - -- Special handling applied - is_refrigerated BOOLEAN NOT NULL DEFAULT FALSE, - is_fragile BOOLEAN NOT NULL DEFAULT FALSE, - handling_notes TEXT, -- Custom per-shipment notes - - -- Status - status TEXT NOT NULL DEFAULT 'created', -- created | label_voided | cancelled - fedex_shipment_id TEXT, -- FedEx internal shipment ID (for voiding) - - -- Audit - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - created_by UUID REFERENCES admin_users(user_id) -); - -ALTER TABLE public.shipments ENABLE ROW LEVEL SECURITY; - --- Shipments visible to brand admins and platform admins for the order's brand -CREATE POLICY brand_admin_view_shipments ON public.shipments - FOR SELECT - USING ( - EXISTS ( - SELECT 1 FROM orders o - WHERE o.id = shipments.order_id - AND o.brand_id = ( - SELECT brand_id FROM admin_users WHERE user_id = auth.uid() LIMIT 1 - ) - ) - OR EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - ) - ); - -CREATE POLICY brand_admin_manage_shipments ON public.shipments - FOR ALL - USING ( - EXISTS ( - SELECT 1 FROM orders o - WHERE o.id = shipments.order_id - AND o.brand_id = ( - SELECT brand_id FROM admin_users WHERE user_id = auth.uid() LIMIT 1 - ) - AND EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.brand_id = o.brand_id - AND au.role IN ('brand_admin', 'platform_admin') - ) - ) - ); - -CREATE INDEX IF NOT EXISTS shipments_order_idx ON public.shipments (order_id); -CREATE INDEX IF NOT EXISTS shipments_tracking_idx ON public.shipments (tracking_number); -CREATE INDEX IF NOT EXISTS shipments_status_idx ON public.shipments (status); \ No newline at end of file diff --git a/supabase/migrations/.archived/085_brand_settings.sql b/supabase/migrations/.archived/085_brand_settings.sql deleted file mode 100644 index 39d4f04..0000000 --- a/supabase/migrations/.archived/085_brand_settings.sql +++ /dev/null @@ -1,154 +0,0 @@ --- Migration 085: Brand Settings Table --- Single source of truth for brand/company information. --- Used by email templates, invoices, site headers, and all brand-facing output. - -CREATE TABLE IF NOT EXISTS public.brand_settings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - - -- Company info - legal_business_name TEXT, - phone TEXT, - email TEXT, - website_url TEXT, - - -- Address - street_address TEXT, - city TEXT, - state TEXT, - postal_code TEXT, - country TEXT DEFAULT 'US', - - -- Logos (Supabase Storage public URLs) - logo_url TEXT, - logo_url_dark TEXT, -- For dark backgrounds (optional) - - -- Branding for emails/invoices - default_email_signature TEXT, - invoice_footer_notes TEXT, - - -- Meta - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX IF NOT EXISTS brand_settings_brand_idx - ON public.brand_settings (brand_id); - -ALTER TABLE public.brand_settings ENABLE ROW LEVEL SECURITY; - --- Brand admins and platform admins can manage their own settings -CREATE POLICY brand_admin_manage_brand_settings ON public.brand_settings - FOR ALL - USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.brand_id = brand_settings.brand_id - AND au.role IN ('brand_admin', 'platform_admin') - ) - ); - --- Read policy: brand admins + platform admins can read -CREATE POLICY brand_admin_read_brand_settings ON public.brand_settings - FOR SELECT - USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.brand_id = brand_settings.brand_id - ) - ); - --- ── RPCs ───────────────────────────────────────────────────────────────────── - -CREATE OR REPLACE FUNCTION public.get_brand_settings(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_build_object( - 'id', bs.id, - 'brand_id', bs.brand_id, - 'legal_business_name', bs.legal_business_name, - 'phone', bs.phone, - 'email', bs.email, - 'website_url', bs.website_url, - 'street_address', bs.street_address, - 'city', bs.city, - 'state', bs.state, - 'postal_code', bs.postal_code, - 'country', bs.country, - 'logo_url', bs.logo_url, - 'logo_url_dark', bs.logo_url_dark, - 'default_email_signature', bs.default_email_signature, - 'invoice_footer_notes', bs.invoice_footer_notes, - 'updated_at', bs.updated_at, - 'brand_name', b.name - ) - INTO v_result - FROM brand_settings bs - JOIN brands b ON b.id = bs.brand_id - WHERE bs.brand_id = p_brand_id; - - RETURN COALESCE(v_result, jsonb_build_object('brand_id', p_brand_id, 'brand_name', ( - SELECT name FROM brands WHERE id = p_brand_id - ))); -END; -$$; - -CREATE OR REPLACE FUNCTION public.upsert_brand_settings( - p_brand_id UUID, - p_legal_business_name TEXT DEFAULT NULL, - p_phone TEXT DEFAULT NULL, - p_email TEXT DEFAULT NULL, - p_website_url TEXT DEFAULT NULL, - p_street_address TEXT DEFAULT NULL, - p_city TEXT DEFAULT NULL, - p_state TEXT DEFAULT NULL, - p_postal_code TEXT DEFAULT NULL, - p_country TEXT DEFAULT NULL, - p_logo_url TEXT DEFAULT NULL, - p_logo_url_dark TEXT DEFAULT NULL, - p_default_email_signature TEXT DEFAULT NULL, - p_invoice_footer_notes TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_id UUID; -BEGIN - INSERT INTO brand_settings ( - brand_id, legal_business_name, phone, email, website_url, - street_address, city, state, postal_code, country, - logo_url, logo_url_dark, default_email_signature, invoice_footer_notes - ) VALUES ( - p_brand_id, p_legal_business_name, p_phone, p_email, p_website_url, - p_street_address, p_city, p_state, p_postal_code, p_country, - p_logo_url, p_logo_url_dark, p_default_email_signature, p_invoice_footer_notes - ) - ON CONFLICT (brand_id) DO UPDATE SET - legal_business_name = COALESCE(p_legal_business_name, brand_settings.legal_business_name), - phone = COALESCE(p_phone, brand_settings.phone), - email = COALESCE(p_email, brand_settings.email), - website_url = COALESCE(p_website_url, brand_settings.website_url), - street_address = COALESCE(p_street_address, brand_settings.street_address), - city = COALESCE(p_city, brand_settings.city), - state = COALESCE(p_state, brand_settings.state), - postal_code = COALESCE(p_postal_code, brand_settings.postal_code), - country = COALESCE(p_country, brand_settings.country), - logo_url = COALESCE(p_logo_url, brand_settings.logo_url), - logo_url_dark = COALESCE(p_logo_url_dark, brand_settings.logo_url_dark), - default_email_signature = COALESCE(p_default_email_signature, brand_settings.default_email_signature), - invoice_footer_notes = COALESCE(p_invoice_footer_notes, brand_settings.invoice_footer_notes), - updated_at = now() - WHERE brand_settings.brand_id = p_brand_id - RETURNING id INTO v_id; - - -- Return the updated record - RETURN get_brand_settings(p_brand_id); -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/086_brand_settings_email_integration.sql b/supabase/migrations/.archived/086_brand_settings_email_integration.sql deleted file mode 100644 index f294a05..0000000 --- a/supabase/migrations/.archived/086_brand_settings_email_integration.sql +++ /dev/null @@ -1,330 +0,0 @@ --- Migration 086: Enhance email substitution with brand_settings --- Updates send_campaign and send_stop_blast to: --- 1. Look up brand_settings for legal_business_name (falls back to brands.name) --- 2. Append default_email_signature to outbound emails if set --- 3. Support {{address}}, {{phone}}, {{email}}, {{website}} variables --- --- Variable substitution priority: --- {{company_name}} → legal_business_name (brand_settings) → brands.name → fallback --- {{address}} → street_address, city, state postal_code from brand_settings --- {{phone}} → phone from brand_settings --- {{email}} → email from brand_settings --- {{website}} → website_url from brand_settings --- {{signature}} → default_email_signature from brand_settings (appended to body) - --- ── Enhanced send_campaign ──────────────────────────────────────────────────── - -CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID, p_brand_id UUID DEFAULT NULL) -RETURNS jsonb -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_campaign communication_campaigns; - v_settings communication_settings; - v_brand_settings RECORD; - v_brand_name TEXT := 'Route Commerce'; - v_signature TEXT; - v_address TEXT; - v_phone TEXT; - v_email TEXT; - v_website TEXT; - v_body_final TEXT; - v_subj_final TEXT; - v_audience JSONB; - v_entries JSONB := '[]'::JSONB; - v_customer JSONB; - v_count INTEGER := 0; -BEGIN - SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Campaign not found'); - END IF; - - -- Resolve effective brand_id - IF p_brand_id IS NOT NULL THEN - -- Use provided brand_id - ELSIF v_campaign.brand_id IS NOT NULL THEN - p_brand_id := v_campaign.brand_id; - END IF; - - -- Look up brand_settings for extended variables - SELECT - bs.legal_business_name, - bs.phone, - bs.email, - bs.website_url, - bs.default_email_signature, - bs.street_address, - bs.city, - bs.state, - bs.postal_code, - b.name AS brand_name - INTO v_brand_settings - FROM brand_settings bs - JOIN brands b ON b.id = bs.brand_id - WHERE bs.brand_id = p_brand_id; - - -- Build brand name: legal_business_name > brand_name > fallback - v_brand_name := COALESCE( - v_brand_settings.legal_business_name, - v_brand_settings.brand_name, - 'Route Commerce' - ); - - -- Build full address string - IF v_brand_settings.street_address IS NOT NULL THEN - v_address := v_brand_settings.street_address - || ', ' || COALESCE(v_brand_settings.city, '') - || ', ' || COALESCE(v_brand_settings.state, '') - || ' ' || COALESCE(v_brand_settings.postal_code, ''); - -- Clean up extra commas from empty parts - v_address := trim(both ',' from v_address); - ELSE - v_address := NULL; - END IF; - - v_phone := v_brand_settings.phone; - v_email := v_brand_settings.email; - v_website := v_brand_settings.website_url; - v_signature := v_brand_settings.default_email_signature; - - -- Apply substitutions to subject - v_subj_final := v_campaign.subject; - v_subj_final := replace(v_subj_final, '{{company_name}}', COALESCE(v_brand_name, 'Route Commerce')); - v_subj_final := replace(v_subj_final, '{{brand_name}}', COALESCE(v_brand_name, 'Route Commerce')); - v_subj_final := replace(v_subj_final, '{{address}}', COALESCE(v_address, '')); - v_subj_final := replace(v_subj_final, '{{phone}}', COALESCE(v_phone, '')); - v_subj_final := replace(v_subj_final, '{{email}}', COALESCE(v_email, '')); - v_subj_final := replace(v_subj_final, '{{website}}', COALESCE(v_website, '')); - - -- Apply substitutions to body, then append signature - v_body_final := v_campaign.body_text; - v_body_final := replace(v_body_final, '{{company_name}}', COALESCE(v_brand_name, 'Route Commerce')); - v_body_final := replace(v_body_final, '{{brand_name}}', COALESCE(v_brand_name, 'Route Commerce')); - v_body_final := replace(v_body_final, '{{address}}', COALESCE(v_address, '')); - v_body_final := replace(v_body_final, '{{phone}}', COALESCE(v_phone, '')); - v_body_final := replace(v_body_final, '{{email}}', COALESCE(v_email, '')); - v_body_final := replace(v_body_final, '{{website}}', COALESCE(v_website, '')); - - -- Remove {{signature}} placeholder from body (replaced with actual signature below) - v_body_final := replace(v_body_final, '{{signature}}', ''); - - -- Append default email signature if set - IF v_signature IS NOT NULL AND v_signature != '' THEN - v_body_final := v_body_final || E'\n\n' || v_signature; - END IF; - - SELECT * INTO v_settings - FROM communication_settings - WHERE brand_id = p_brand_id; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand'); - END IF; - - v_audience := preview_campaign_audience(p_brand_id, v_campaign.audience_rules); - - FOR v_customer IN - SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb)) - LOOP - v_entries := v_entries || jsonb_build_array(jsonb_build_object( - 'brand_id', p_brand_id, - 'campaign_id', p_campaign_id, - 'customer_id', v_customer->>'id', - 'customer_email', v_customer->>'email', - 'delivery_method', 'email', - 'subject', v_subj_final, - 'body_preview', left(v_body_final, 500), - 'status', 'queued' - )); - v_count := v_count + 1; - END LOOP; - - PERFORM log_communication_messages(v_entries); - - UPDATE communication_campaigns - SET status = 'sent', sent_at = now(), updated_at = now() - WHERE id = p_campaign_id; - - RETURN jsonb_build_object('success', true, 'messages_logged', v_count); -END; -$$; - --- ── Enhanced send_stop_blast ─────────────────────────────────────────────────── - -CREATE OR REPLACE FUNCTION public.send_stop_blast( - p_stop_id UUID, - p_brand_id UUID, - p_body TEXT, - p_channel TEXT DEFAULT 'email', - p_subject TEXT DEFAULT NULL, - p_audience TEXT DEFAULT 'pending', - p_created_by UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_campaign_id UUID; - v_count INT := 0; - v_order RECORD; - v_contact JSONB; - v_customer_id UUID; - v_opted_in BOOLEAN; - v_email TEXT; - v_phone TEXT; - v_brand_name TEXT := 'Route Commerce'; - v_signature TEXT; - v_address TEXT; - v_subj TEXT; - v_body_sub TEXT; - v_brand_settings RECORD; -BEGIN - -- Look up brand_settings for extended variables - SELECT - bs.legal_business_name, - bs.phone, - bs.email, - bs.website_url, - bs.default_email_signature, - bs.street_address, - bs.city, - bs.state, - bs.postal_code, - b.name AS brand_name - INTO v_brand_settings - FROM brand_settings bs - JOIN brands b ON b.id = p_brand_id - WHERE bs.brand_id = p_brand_id; - - -- Build brand name: legal_business_name > brand_name > fallback - v_brand_name := COALESCE( - v_brand_settings.legal_business_name, - v_brand_settings.brand_name, - 'Route Commerce' - ); - - -- Build full address string - IF v_brand_settings.street_address IS NOT NULL THEN - v_address := v_brand_settings.street_address - || ', ' || COALESCE(v_brand_settings.city, '') - || ', ' || COALESCE(v_brand_settings.state, '') - || ' ' || COALESCE(v_brand_settings.postal_code, ''); - v_address := trim(both ',' from v_address); - ELSE - v_address := NULL; - END IF; - - v_signature := v_brand_settings.default_email_signature; - - -- Apply substitutions to subject - v_subj := p_subject; - IF v_subj IS NOT NULL THEN - v_subj := replace(v_subj, '{{company_name}}', COALESCE(v_brand_name, 'Route Commerce')); - v_subj := replace(v_subj, '{{brand_name}}', COALESCE(v_brand_name, 'Route Commerce')); - v_subj := replace(v_subj, '{{address}}', COALESCE(v_address, '')); - v_subj := replace(v_subj, '{{phone}}', COALESCE(v_brand_settings.phone, '')); - v_subj := replace(v_subj, '{{email}}', COALESCE(v_brand_settings.email, '')); - v_subj := replace(v_subj, '{{website}}', COALESCE(v_brand_settings.website_url, '')); - END IF; - - -- Apply substitutions to body, then append signature - v_body_sub := p_body; - v_body_sub := replace(v_body_sub, '{{company_name}}', COALESCE(v_brand_name, 'Route Commerce')); - v_body_sub := replace(v_body_sub, '{{brand_name}}', COALESCE(v_brand_name, 'Route Commerce')); - v_body_sub := replace(v_body_sub, '{{address}}', COALESCE(v_address, '')); - v_body_sub := replace(v_body_sub, '{{phone}}', COALESCE(v_brand_settings.phone, '')); - v_body_sub := replace(v_body_sub, '{{email}}', COALESCE(v_brand_settings.email, '')); - v_body_sub := replace(v_body_sub, '{{website}}', COALESCE(v_brand_settings.website_url, '')); - - -- Append default email signature if set - IF v_signature IS NOT NULL AND v_signature != '' THEN - v_body_sub := v_body_sub || E'\n\n' || v_signature; - END IF; - - -- Create campaign record - INSERT INTO communication_campaigns ( - brand_id, name, subject, body_text, - campaign_type, status, audience_rules, - sent_at, created_by - ) VALUES ( - p_brand_id, - 'Stop blast: ' || COALESCE(p_subject, p_body), - v_subj, - v_body_sub, - 'operational', - 'sent', - jsonb_build_object( - 'target', 'stop', - 'stop_id', p_stop_id, - 'date_from', NULL, - 'date_to', NULL - ), - now(), - p_created_by - ) RETURNING id INTO v_campaign_id; - - -- Fetch matching orders - FOR v_order IN - SELECT o.id, o.customer_id, o.customer_name, - o.customer_email, o.customer_phone, o.pickup_complete, - c.email_opt_in, c.sms_opt_in, c.unsubscribed_at - FROM orders o - LEFT JOIN communication_contacts c ON c.id = o.customer_id - WHERE o.stop_id = p_stop_id - AND ( - p_audience = 'all' - OR (p_audience = 'pending' AND NOT o.pickup_complete) - OR (p_audience = 'picked_up' AND o.pickup_complete) - ) - LOOP - -- Check opt-in status per channel - v_opted_in := ( - CASE p_channel - WHEN 'sms' THEN v_order.customer_phone IS NOT NULL - AND COALESCE(v_order.sms_opt_in, FALSE) = TRUE - AND v_order.unsubscribed_at IS NULL - WHEN 'email' THEN v_order.customer_email IS NOT NULL - AND COALESCE(v_order.email_opt_in, TRUE) = TRUE - AND v_order.unsubscribed_at IS NULL - ELSE v_order.customer_email IS NOT NULL - AND COALESCE(v_order.email_opt_in, TRUE) = TRUE - AND v_order.unsubscribed_at IS NULL - END - ); - - IF NOT v_opted_in THEN - CONTINUE; - END IF; - - -- Log email message with fully-substituted subject and body - IF p_channel IN ('email', 'both') AND v_order.customer_email IS NOT NULL THEN - INSERT INTO communication_message_logs ( - brand_id, campaign_id, customer_id, customer_email, - delivery_method, subject, body_preview, status, sent_at - ) VALUES ( - p_brand_id, v_campaign_id, v_order.customer_id, v_order.customer_email, - 'email', COALESCE(v_subj, ''), LEFT(v_body_sub, 200), 'queued', now() - ); - v_count := v_count + 1; - END IF; - - -- Log SMS message (signature not appended to SMS) - IF p_channel IN ('sms', 'both') AND v_order.customer_phone IS NOT NULL THEN - INSERT INTO communication_message_logs ( - brand_id, campaign_id, customer_id, customer_phone, - delivery_method, body_preview, status, sent_at - ) VALUES ( - p_brand_id, v_campaign_id, v_order.customer_id, v_order.customer_phone, - 'sms', LEFT(v_body_sub, 160), 'queued', now() - ); - v_count := v_count + 1; - END IF; - END LOOP; - - RETURN jsonb_build_object( - 'success', true, - 'campaign_id', v_campaign_id, - 'messages_logged', v_count - ); -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/087_brand_logos_bucket.sql b/supabase/migrations/.archived/087_brand_logos_bucket.sql deleted file mode 100644 index 15a7d5a..0000000 --- a/supabase/migrations/.archived/087_brand_logos_bucket.sql +++ /dev/null @@ -1,77 +0,0 @@ --- Migration 087: Brand Logos Storage Bucket --- --- SETUP REQUIRED (one-time, run manually in Supabase dashboard): --- 1. Go to Storage → New bucket --- 2. Name: "brand-logos" | Public: true --- 3. Allowed MIME types: image/png, image/jpeg, image/webp, image/svg+xml --- 4. Max file size: 5MB --- --- Files stored at: brand-logos/{brand_id}/logo.png, brand-logos/{brand_id}/logo-dark.png - --- RLS policies for brand-logos bucket --- Brand admins can upload/update their own brand's logos --- Everyone can read logos (public bucket) - -CREATE POLICY IF NOT EXISTS "brand_admin_upload_logo" -ON storage.objects -FOR INSERT -WITH CHECK ( - bucket_id = 'brand-logos' - AND ( - -- Platform admin can upload to any brand - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() AND au.role = 'platform_admin' - ) - OR - -- Brand admin can upload to their own brand's folder - (storage.foldername(name))[1] IN ( - SELECT au.brand_id::text FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'brand_admin' - ) - ) -); - -CREATE POLICY IF NOT EXISTS "public_read_logo" -ON storage.objects -FOR SELECT -USING (bucket_id = 'brand-logos'); - -CREATE POLICY IF NOT EXISTS "brand_admin_update_logo" -ON storage.objects -FOR UPDATE -USING ( - bucket_id = 'brand-logos' - AND ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() AND au.role = 'platform_admin' - ) - OR - (storage.foldername(name))[1] IN ( - SELECT au.brand_id::text FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'brand_admin' - ) - ) -); - -CREATE POLICY IF NOT EXISTS "brand_admin_delete_logo" -ON storage.objects -FOR DELETE -USING ( - bucket_id = 'brand-logos' - AND ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() AND au.role = 'platform_admin' - ) - OR - (storage.foldername(name))[1] IN ( - SELECT au.brand_id::text FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'brand_admin' - ) - ) -); \ No newline at end of file diff --git a/supabase/migrations/.archived/088_brand_features.sql b/supabase/migrations/.archived/088_brand_features.sql deleted file mode 100644 index bc96729..0000000 --- a/supabase/migrations/.archived/088_brand_features.sql +++ /dev/null @@ -1,64 +0,0 @@ --- Migration 088: Brand feature flags / add-on licensing system --- Enables per-brand add-on enablement for Harvest Reach, Wholesale Portal, Water Log, AI Tools, etc. - -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, -- e.g., 'harvest_reach', 'wholesale_portal', 'water_log', 'ai_tools' - enabled BOOLEAN NOT NULL DEFAULT false, - enabled_at TIMESTAMPTZ, - disabled_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (brand_id, feature_key) -); - --- Index for fast lookups -CREATE INDEX IF NOT EXISTS brand_features_brand_id_idx ON brand_features(brand_id); - --- RPC to get all features for a brand -CREATE OR REPLACE FUNCTION get_brand_features(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - result JSONB; -BEGIN - result := jsonb_object_agg( - f.feature_key, - CASE WHEN f.enabled AND f.disabled_at IS NULL THEN true ELSE false END - ) - FROM brand_features f - WHERE f.brand_id = p_brand_id; - - -- If no features set, return empty object (env vars will be fallback) - RETURN COALESCE(result, '{}'::jsonb); -END; -$$; - --- RPC to set a single feature on/off -CREATE OR REPLACE FUNCTION set_brand_feature( - p_brand_id UUID, - p_feature_key TEXT, - p_enabled BOOLEAN -) -RETURNS BOOLEAN -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - INSERT INTO brand_features (brand_id, feature_key, enabled, enabled_at) - VALUES (p_brand_id, p_feature_key, p_enabled, CASE WHEN p_enabled THEN now() ELSE NULL END) - ON CONFLICT (brand_id, feature_key) - DO UPDATE SET - enabled = p_enabled, - enabled_at = CASE WHEN p_enabled AND brand_features.enabled_at IS NULL THEN now() ELSE brand_features.enabled_at END, - disabled_at = CASE WHEN NOT p_enabled THEN now() ELSE NULL END; - RETURN true; -END; -$$; - --- Grant access -GRANT SELECT, INSERT, UPDATE, DELETE ON brand_features TO authenticated; -GRANT EXECUTE ON FUNCTION get_brand_features(UUID) TO authenticated; -GRANT EXECUTE ON FUNCTION set_brand_feature(UUID, TEXT, BOOLEAN) TO authenticated; \ No newline at end of file diff --git a/supabase/migrations/.archived/089_ai_providers_custom_integrations.sql b/supabase/migrations/.archived/089_ai_providers_custom_integrations.sql deleted file mode 100644 index 33a58aa..0000000 --- a/supabase/migrations/.archived/089_ai_providers_custom_integrations.sql +++ /dev/null @@ -1,134 +0,0 @@ --- Migration 089: AI providers config + custom integrations --- Store per-brand AI provider settings and custom integration definitions - -ALTER TABLE brand_settings - ADD COLUMN IF NOT EXISTS ai_provider_settings JSONB DEFAULT '{ - "provider": "openai", - "api_key": null, - "org_id": null, - "model": "gpt-4o-mini", - "custom_endpoint": null - }'::jsonb; - -ALTER TABLE brand_settings - ADD COLUMN IF NOT EXISTS custom_integrations JSONB DEFAULT '[]'::jsonb; - --- RPC to get AI provider config -CREATE OR REPLACE FUNCTION get_ai_provider_settings(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN COALESCE( - (SELECT ai_provider_settings FROM brand_settings WHERE brand_id = p_brand_id), - '{"provider": "openai", "model": "gpt-4o-mini"}'::jsonb - ); -END; -$$; - --- RPC to save AI provider config -CREATE OR REPLACE FUNCTION set_ai_provider_settings( - p_brand_id UUID, - p_settings JSONB -) -RETURNS BOOLEAN -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - UPDATE brand_settings - SET ai_provider_settings = p_settings, - updated_at = now() - WHERE brand_id = p_brand_id; - RETURN true; -END; -$$; - --- RPC to get custom integrations -CREATE OR REPLACE FUNCTION get_custom_integrations(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN COALESCE( - (SELECT custom_integrations FROM brand_settings WHERE brand_id = p_brand_id), - '[]'::jsonb - ); -END; -$$; - --- RPC to add/update a custom integration -CREATE OR REPLACE FUNCTION upsert_custom_integration( - p_brand_id UUID, - p_integration JSONB -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - current_integrations JSONB; - updated_integrations JSONB; - integration_id TEXT; -BEGIN - current_integrations := COALESCE( - (SELECT custom_integrations FROM brand_settings WHERE brand_id = p_brand_id), - '[]'::jsonb - ); - - integration_id := p_integration->>'id'; - - -- Remove existing with same id, then append - current_integrations := ( - SELECT jsonb_agg(item) - FROM ( - SELECT item - FROM jsonb_array_elements(current_integrations) item - WHERE item->>'id' != integration_id - UNION ALL - SELECT p_integration - ) t - ); - - UPDATE brand_settings - SET custom_integrations = current_integrations, - updated_at = now() - WHERE brand_id = p_brand_id; - - RETURN current_integrations; -END; -$$; - --- RPC to delete a custom integration -CREATE OR REPLACE FUNCTION delete_custom_integration( - p_brand_id UUID, - p_integration_id TEXT -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - UPDATE brand_settings - SET custom_integrations = ( - SELECT jsonb_agg(item) - FROM ( - SELECT item - FROM jsonb_array_elements(custom_integrations) item - WHERE item->>'id' != p_integration_id - ) t - ), - updated_at = now() - WHERE brand_id = p_brand_id; - - RETURN (SELECT custom_integrations FROM brand_settings WHERE brand_id = p_brand_id); -END; -$$; - -GRANT EXECUTE ON FUNCTION get_ai_provider_settings(UUID) TO authenticated; -GRANT EXECUTE ON FUNCTION set_ai_provider_settings(UUID, JSONB) TO authenticated; -GRANT EXECUTE ON FUNCTION get_custom_integrations(UUID) TO authenticated; -GRANT EXECUTE ON FUNCTION upsert_custom_integration(UUID, JSONB) TO authenticated; -GRANT EXECUTE ON FUNCTION delete_custom_integration(UUID, TEXT) TO authenticated; \ No newline at end of file diff --git a/supabase/migrations/.archived/090_storefront_settings.sql b/supabase/migrations/.archived/090_storefront_settings.sql deleted file mode 100644 index df38667..0000000 --- a/supabase/migrations/.archived/090_storefront_settings.sql +++ /dev/null @@ -1,55 +0,0 @@ --- Enable UUID extension if not already enabled -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- Storefront customization fields added to brand_settings -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS hero_tagline TEXT; -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS about_headline TEXT; -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS about_subheadline TEXT; -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS custom_footer_text TEXT; -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS show_wholesale_link BOOLEAN DEFAULT true; -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS show_zip_search BOOLEAN DEFAULT true; -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS show_schedule_pdf BOOLEAN DEFAULT true; -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS show_text_alerts BOOLEAN DEFAULT false; -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS schedule_pdf_notes TEXT; - --- New RPC: get brand settings by slug (public, no auth required for brand-specific storefront pages) -CREATE OR REPLACE FUNCTION get_brand_settings_by_slug(p_brand_slug TEXT) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - result JSONB; -BEGIN - SELECT jsonb_build_object( - 'brand_id', bs.brand_id, - 'brand_name', b.name, - 'hero_tagline', bs.hero_tagline, - 'about_headline', bs.about_headline, - 'about_subheadline', bs.about_subheadline, - 'custom_footer_text', bs.custom_footer_text, - 'show_wholesale_link', bs.show_wholesale_link, - 'show_zip_search', bs.show_zip_search, - 'show_schedule_pdf', bs.show_schedule_pdf, - 'show_text_alerts', bs.show_text_alerts, - 'schedule_pdf_notes', bs.schedule_pdf_notes, - 'logo_url', bs.logo_url, - 'logo_url_dark', bs.logo_url_dark, - 'contact_email', bs.email, - 'contact_phone', bs.phone, - 'legal_business_name', bs.legal_business_name, - 'wholesale_enabled', ws.wholesale_enabled - ) - INTO result - FROM brands b - LEFT JOIN brand_settings bs ON bs.brand_id = b.id - LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id - WHERE b.slug = p_brand_slug; - - RETURN result; -END; -$$; - -COMMENT ON FUNCTION get_brand_settings_by_slug IS - 'Returns brand settings merged with wholesale_enabled for public storefront pages. No auth required.'; \ No newline at end of file diff --git a/supabase/migrations/.archived/091_brand_plan_tier.sql b/supabase/migrations/.archived/091_brand_plan_tier.sql deleted file mode 100644 index 44d6a46..0000000 --- a/supabase/migrations/.archived/091_brand_plan_tier.sql +++ /dev/null @@ -1,83 +0,0 @@ --- Add plan_tier column to brands -ALTER TABLE public.brands - ADD COLUMN IF NOT EXISTS plan_tier TEXT NOT NULL DEFAULT 'starter' - CHECK (plan_tier IN ('starter', 'farm', 'enterprise')); - --- Add stripe_customer_id for Stripe billing portal -ALTER TABLE public.brands - ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT; - --- Add plan limits columns -ALTER TABLE public.brands - ADD COLUMN IF NOT EXISTS max_users INTEGER DEFAULT 1, - ADD COLUMN IF NOT EXISTS max_stops_monthly INTEGER DEFAULT 10, - ADD COLUMN IF NOT EXISTS max_products INTEGER DEFAULT 25; - --- RPC: update_brand_plan_tier -CREATE OR REPLACE FUNCTION public.update_brand_plan_tier( - p_brand_id UUID, - p_plan_tier TEXT -) RETURNS VOID AS $$ -BEGIN - IF p_plan_tier NOT IN ('starter', 'farm', 'enterprise') THEN - RAISE EXCEPTION 'Invalid plan tier: %', p_plan_tier; - END IF; - UPDATE brands SET - plan_tier = p_plan_tier, - updated_at = now() - WHERE id = p_brand_id; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- RPC: update_brand_stripe_customer_id -CREATE OR REPLACE FUNCTION public.update_brand_stripe_customer_id( - p_brand_id UUID, - p_stripe_customer_id TEXT -) RETURNS VOID AS $$ -BEGIN - UPDATE brands SET - stripe_customer_id = p_stripe_customer_id, - updated_at = now() - WHERE id = p_brand_id; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- RPC: get_brand_plan_info (returns plan tier + limits + usage stats) -CREATE OR REPLACE FUNCTION public.get_brand_plan_info(p_brand_id UUID) -RETURNS JSONB AS $$ -DECLARE - v_result JSONB; - v_user_count BIGINT; - v_active_stops BIGINT; - v_product_count BIGINT; - v_brand RECORD; -BEGIN - SELECT plan_tier, max_users, max_stops_monthly, max_products, stripe_customer_id - INTO v_brand - FROM brands WHERE id = p_brand_id; - - IF v_brand IS NULL THEN - RETURN NULL; - END IF; - - -- Count current usage - SELECT COUNT(*) INTO v_user_count FROM admin_users WHERE brand_id = p_brand_id AND deleted_at IS NULL; - SELECT COUNT(*) INTO v_active_stops FROM stops WHERE brand_id = p_brand_id AND active = true AND date >= date_trunc('month', now()); - SELECT COUNT(*) INTO v_product_count FROM products WHERE brand_id = p_brand_id AND deleted_at IS NULL; - - SELECT jsonb_build_object( - 'plan_tier', v_brand.plan_tier, - 'max_users', v_brand.max_users, - 'max_stops_monthly', v_brand.max_stops_monthly, - 'max_products', v_brand.max_products, - 'stripe_customer_id', v_brand.stripe_customer_id, - 'usage', jsonb_build_object( - 'users', v_user_count, - 'stops_this_month', v_active_stops, - 'products', v_product_count - ) - ) INTO v_result; - - RETURN v_result; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; \ No newline at end of file diff --git a/supabase/migrations/.archived/092_stripe_subscriptions.sql b/supabase/migrations/.archived/092_stripe_subscriptions.sql deleted file mode 100644 index 76f16c5..0000000 --- a/supabase/migrations/.archived/092_stripe_subscriptions.sql +++ /dev/null @@ -1,74 +0,0 @@ --- Migration 092: Stripe Subscriptions + Billing Integration --- Enables real Stripe subscription management for platform billing - --- ── Subscription tracking columns on brands ──────────────────────────────── -ALTER TABLE brands ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT; -ALTER TABLE brands ADD COLUMN IF NOT EXISTS stripe_subscription_status TEXT DEFAULT 'inactive'; -ALTER TABLE brands ADD COLUMN IF NOT EXISTS stripe_current_period_end TIMESTAMPTZ; - --- ── RPC: Save subscription data from Stripe webhook ───────────────────────── -CREATE OR REPLACE FUNCTION set_brand_subscription( - p_brand_id UUID, - p_subscription_id TEXT, - p_status TEXT, - p_current_period_end TIMESTAMPTZ -) RETURNS BOOLEAN AS $$ -BEGIN - UPDATE brands SET - stripe_subscription_id = p_subscription_id, - stripe_subscription_status = p_status, - stripe_current_period_end = p_current_period_end, - updated_at = NOW() - WHERE id = p_brand_id; - RETURN TRUE; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - -GRANT EXECUTE ON FUNCTION set_brand_subscription(UUID, TEXT, TEXT, TIMESTAMPTZ) TO authenticated; - --- ── RPC: Get subscription info ───────────────────────────────────────────── -CREATE OR REPLACE FUNCTION get_brand_subscription(p_brand_id UUID) -RETURNS JSONB AS $$ -DECLARE - result JSONB; -BEGIN - SELECT jsonb_build_object( - 'stripe_subscription_id', stripe_subscription_id, - 'stripe_subscription_status', stripe_subscription_status, - 'stripe_current_period_end', stripe_current_period_end, - 'stripe_customer_id', stripe_customer_id, - 'plan_tier', plan_tier - ) INTO result - FROM brands WHERE id = p_brand_id; - - RETURN result; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - -GRANT EXECUTE ON FUNCTION get_brand_subscription(UUID) TO authenticated; - --- ── Stripe price IDs (environment-variable-driven) ───────────────────────── --- These should be set in .env.local: --- STRIPE_PRICE_STARTER=price_xxxx --- STRIPE_PRICE_FARM=price_xxxx --- STRIPE_PRICE_ENTERPRISE=price_xxxx --- STRIPE_PRICE_HARVEST_REACH=price_xxxx (add-on) --- STRIPE_PRICE_WHOLESALE_PORTAL=price_xxxx --- STRIPE_PRICE_WATER_LOG=price_xxxx --- STRIPE_PRICE_AI_TOOLS=price_xxxx --- STRIPE_PRICE_SQUARE_SYNC=price_xxxx --- STRIPE_PRICE_SMS_CAMPAIGNS=price_xxxx - --- ── Feature flag auto-enable map ─────────────────────────────────────────── --- When Stripe subscription is active for a given price, the corresponding --- brand_features flag is enabled. When subscription is canceled, it's disabled. --- --- Price ID → feature key mapping (configured via env vars): --- STRIPE_PRICE_HARVEST_REACH → harvest_reach --- STRIPE_PRICE_WHOLESALE_PORTAL → wholesale_portal --- STRIPE_PRICE_WATER_LOG → water_log --- STRIPE_PRICE_AI_TOOLS → ai_tools --- STRIPE_PRICE_SQUARE_SYNC → square_sync --- STRIPE_PRICE_SMS_CAMPAIGNS → sms_campaigns --- --- Plan tier changes are handled separately via plan upgrade checkout sessions. \ No newline at end of file diff --git a/supabase/migrations/.archived/093_add_hero_image.sql b/supabase/migrations/.archived/093_add_hero_image.sql deleted file mode 100644 index 2186bd5..0000000 --- a/supabase/migrations/.archived/093_add_hero_image.sql +++ /dev/null @@ -1,170 +0,0 @@ --- Migration 093: Add hero_image_url to brand_settings --- Storefront hero image (full-width background photo) - -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS hero_image_url TEXT; - --- Update upsert_brand_settings to accept hero_image_url -CREATE OR REPLACE FUNCTION public.upsert_brand_settings( - p_brand_id UUID, - p_legal_business_name TEXT DEFAULT NULL, - p_phone TEXT DEFAULT NULL, - p_email TEXT DEFAULT NULL, - p_website_url TEXT DEFAULT NULL, - p_street_address TEXT DEFAULT NULL, - p_city TEXT DEFAULT NULL, - p_state TEXT DEFAULT NULL, - p_postal_code TEXT DEFAULT NULL, - p_country TEXT DEFAULT NULL, - p_logo_url TEXT DEFAULT NULL, - p_logo_url_dark TEXT DEFAULT NULL, - p_default_email_signature TEXT DEFAULT NULL, - p_invoice_footer_notes TEXT DEFAULT NULL, - p_hero_tagline TEXT DEFAULT NULL, - p_about_headline TEXT DEFAULT NULL, - p_about_subheadline TEXT DEFAULT NULL, - p_custom_footer_text TEXT DEFAULT NULL, - p_show_wholesale_link BOOLEAN DEFAULT NULL, - p_show_zip_search BOOLEAN DEFAULT NULL, - p_show_schedule_pdf BOOLEAN DEFAULT NULL, - p_show_text_alerts BOOLEAN DEFAULT NULL, - p_schedule_pdf_notes TEXT DEFAULT NULL, - p_hero_image_url TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_id UUID; -BEGIN - INSERT INTO brand_settings ( - brand_id, legal_business_name, phone, email, website_url, - street_address, city, state, postal_code, country, - logo_url, logo_url_dark, default_email_signature, invoice_footer_notes, - hero_tagline, about_headline, about_subheadline, custom_footer_text, - show_wholesale_link, show_zip_search, show_schedule_pdf, - show_text_alerts, schedule_pdf_notes, hero_image_url - ) VALUES ( - p_brand_id, p_legal_business_name, p_phone, p_email, p_website_url, - p_street_address, p_city, p_state, p_postal_code, p_country, - p_logo_url, p_logo_url_dark, p_default_email_signature, p_invoice_footer_notes, - p_hero_tagline, p_about_headline, p_about_subheadline, p_custom_footer_text, - p_show_wholesale_link, p_show_zip_search, p_show_schedule_pdf, - p_show_text_alerts, p_schedule_pdf_notes, p_hero_image_url - ) - ON CONFLICT (brand_id) DO UPDATE SET - legal_business_name = COALESCE(p_legal_business_name, brand_settings.legal_business_name), - phone = COALESCE(p_phone, brand_settings.phone), - email = COALESCE(p_email, brand_settings.email), - website_url = COALESCE(p_website_url, brand_settings.website_url), - street_address = COALESCE(p_street_address, brand_settings.street_address), - city = COALESCE(p_city, brand_settings.city), - state = COALESCE(p_state, brand_settings.state), - postal_code = COALESCE(p_postal_code, brand_settings.postal_code), - country = COALESCE(p_country, brand_settings.country), - logo_url = COALESCE(p_logo_url, brand_settings.logo_url), - logo_url_dark = COALESCE(p_logo_url_dark, brand_settings.logo_url_dark), - default_email_signature = COALESCE(p_default_email_signature, brand_settings.default_email_signature), - invoice_footer_notes = COALESCE(p_invoice_footer_notes, brand_settings.invoice_footer_notes), - hero_tagline = COALESCE(p_hero_tagline, brand_settings.hero_tagline), - about_headline = COALESCE(p_about_headline, brand_settings.about_headline), - about_subheadline = COALESCE(p_about_subheadline, brand_settings.about_subheadline), - custom_footer_text = COALESCE(p_custom_footer_text, brand_settings.custom_footer_text), - show_wholesale_link = COALESCE(p_show_wholesale_link, brand_settings.show_wholesale_link), - show_zip_search = COALESCE(p_show_zip_search, brand_settings.show_zip_search), - show_schedule_pdf = COALESCE(p_show_schedule_pdf, brand_settings.show_schedule_pdf), - show_text_alerts = COALESCE(p_show_text_alerts, brand_settings.show_text_alerts), - schedule_pdf_notes = COALESCE(p_schedule_pdf_notes, brand_settings.schedule_pdf_notes), - hero_image_url = COALESCE(p_hero_image_url, brand_settings.hero_image_url), - updated_at = now() - WHERE brand_settings.brand_id = p_brand_id - RETURNING id INTO v_id; - - RETURN get_brand_settings(p_brand_id); -END; -$$; - --- Update get_brand_settings to include hero_image_url -CREATE OR REPLACE FUNCTION public.get_brand_settings(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_build_object( - 'id', bs.id, - 'brand_id', bs.brand_id, - 'legal_business_name', bs.legal_business_name, - 'phone', bs.phone, - 'email', bs.email, - 'website_url', bs.website_url, - 'street_address', bs.street_address, - 'city', bs.city, - 'state', bs.state, - 'postal_code', bs.postal_code, - 'country', bs.country, - 'logo_url', bs.logo_url, - 'logo_url_dark', bs.logo_url_dark, - 'default_email_signature', bs.default_email_signature, - 'invoice_footer_notes', bs.invoice_footer_notes, - 'hero_tagline', bs.hero_tagline, - 'about_headline', bs.about_headline, - 'about_subheadline', bs.about_subheadline, - 'custom_footer_text', bs.custom_footer_text, - 'show_wholesale_link', bs.show_wholesale_link, - 'show_zip_search', bs.show_zip_search, - 'show_schedule_pdf', bs.show_schedule_pdf, - 'show_text_alerts', bs.show_text_alerts, - 'schedule_pdf_notes', bs.schedule_pdf_notes, - 'hero_image_url', bs.hero_image_url, - 'updated_at', bs.updated_at, - 'brand_name', b.name - ) - INTO v_result - FROM brand_settings bs - JOIN brands b ON b.id = bs.brand_id - WHERE bs.brand_id = p_brand_id; - - RETURN COALESCE(v_result, jsonb_build_object('brand_id', p_brand_id, 'brand_name', ( - SELECT name FROM brands WHERE id = p_brand_id - ))); -END; -$$; - --- Update get_brand_settings_by_slug to include hero_image_url -CREATE OR REPLACE FUNCTION public.get_brand_settings_by_slug(p_brand_slug TEXT) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - result JSONB; -BEGIN - SELECT jsonb_build_object( - 'brand_id', bs.brand_id, - 'brand_name', b.name, - 'hero_tagline', bs.hero_tagline, - 'about_headline', bs.about_headline, - 'about_subheadline', bs.about_subheadline, - 'custom_footer_text', bs.custom_footer_text, - 'show_wholesale_link', bs.show_wholesale_link, - 'show_zip_search', bs.show_zip_search, - 'show_schedule_pdf', bs.show_schedule_pdf, - 'show_text_alerts', bs.show_text_alerts, - 'schedule_pdf_notes', bs.schedule_pdf_notes, - 'logo_url', bs.logo_url, - 'logo_url_dark', bs.logo_url_dark, - 'hero_image_url', bs.hero_image_url, - 'contact_email', bs.email, - 'contact_phone', bs.phone, - 'legal_business_name', bs.legal_business_name, - 'wholesale_enabled', ws.wholesale_enabled - ) - INTO result - FROM brands b - LEFT JOIN brand_settings bs ON bs.brand_id = b.id - LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id - WHERE b.slug = p_brand_slug; - - RETURN result; -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/094_brand_color_customization.sql b/supabase/migrations/.archived/094_brand_color_customization.sql deleted file mode 100644 index b948506..0000000 --- a/supabase/migrations/.archived/094_brand_color_customization.sql +++ /dev/null @@ -1,197 +0,0 @@ --- Migration 094: Brand Color Customization --- Add primary/secondary/bg color customization to brand_settings --- Allows admins to customize brand accent colors via brand settings - -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS brand_primary_color TEXT DEFAULT '#16a34a'; -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS brand_secondary_color TEXT DEFAULT '#f5f5f4'; -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS brand_bg_color TEXT DEFAULT '#fafaf9'; -ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS brand_text_color TEXT DEFAULT '#1c1917'; - --- Update upsert_brand_settings to accept new color fields -CREATE OR REPLACE FUNCTION public.upsert_brand_settings( - p_brand_id UUID, - p_legal_business_name TEXT DEFAULT NULL, - p_phone TEXT DEFAULT NULL, - p_email TEXT DEFAULT NULL, - p_website_url TEXT DEFAULT NULL, - p_street_address TEXT DEFAULT NULL, - p_city TEXT DEFAULT NULL, - p_state TEXT DEFAULT NULL, - p_postal_code TEXT DEFAULT NULL, - p_country TEXT DEFAULT NULL, - p_logo_url TEXT DEFAULT NULL, - p_logo_url_dark TEXT DEFAULT NULL, - p_default_email_signature TEXT DEFAULT NULL, - p_invoice_footer_notes TEXT DEFAULT NULL, - p_hero_tagline TEXT DEFAULT NULL, - p_about_headline TEXT DEFAULT NULL, - p_about_subheadline TEXT DEFAULT NULL, - p_custom_footer_text TEXT DEFAULT NULL, - p_show_wholesale_link BOOLEAN DEFAULT NULL, - p_show_zip_search BOOLEAN DEFAULT NULL, - p_show_schedule_pdf BOOLEAN DEFAULT NULL, - p_show_text_alerts BOOLEAN DEFAULT NULL, - p_schedule_pdf_notes TEXT DEFAULT NULL, - p_hero_image_url TEXT DEFAULT NULL, - p_brand_primary_color TEXT DEFAULT NULL, - p_brand_secondary_color TEXT DEFAULT NULL, - p_brand_bg_color TEXT DEFAULT NULL, - p_brand_text_color TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_id UUID; -BEGIN - INSERT INTO brand_settings ( - brand_id, legal_business_name, phone, email, website_url, - street_address, city, state, postal_code, country, - logo_url, logo_url_dark, default_email_signature, invoice_footer_notes, - hero_tagline, about_headline, about_subheadline, custom_footer_text, - show_wholesale_link, show_zip_search, show_schedule_pdf, - show_text_alerts, schedule_pdf_notes, hero_image_url, - brand_primary_color, brand_secondary_color, brand_bg_color, brand_text_color - ) VALUES ( - p_brand_id, p_legal_business_name, p_phone, p_email, p_website_url, - p_street_address, p_city, p_state, p_postal_code, p_country, - p_logo_url, p_logo_url_dark, p_default_email_signature, p_invoice_footer_notes, - p_hero_tagline, p_about_headline, p_about_subheadline, p_custom_footer_text, - p_show_wholesale_link, p_show_zip_search, p_show_schedule_pdf, - p_show_text_alerts, p_schedule_pdf_notes, p_hero_image_url, - p_brand_primary_color, p_brand_secondary_color, p_brand_bg_color, p_brand_text_color - ) - ON CONFLICT (brand_id) DO UPDATE SET - legal_business_name = COALESCE(p_legal_business_name, brand_settings.legal_business_name), - phone = COALESCE(p_phone, brand_settings.phone), - email = COALESCE(p_email, brand_settings.email), - website_url = COALESCE(p_website_url, brand_settings.website_url), - street_address = COALESCE(p_street_address, brand_settings.street_address), - city = COALESCE(p_city, brand_settings.city), - state = COALESCE(p_state, brand_settings.state), - postal_code = COALESCE(p_postal_code, brand_settings.postal_code), - country = COALESCE(p_country, brand_settings.country), - logo_url = COALESCE(p_logo_url, brand_settings.logo_url), - logo_url_dark = COALESCE(p_logo_url_dark, brand_settings.logo_url_dark), - default_email_signature = COALESCE(p_default_email_signature, brand_settings.default_email_signature), - invoice_footer_notes = COALESCE(p_invoice_footer_notes, brand_settings.invoice_footer_notes), - hero_tagline = COALESCE(p_hero_tagline, brand_settings.hero_tagline), - about_headline = COALESCE(p_about_headline, brand_settings.about_headline), - about_subheadline = COALESCE(p_about_subheadline, brand_settings.about_subheadline), - custom_footer_text = COALESCE(p_custom_footer_text, brand_settings.custom_footer_text), - show_wholesale_link = COALESCE(p_show_wholesale_link, brand_settings.show_wholesale_link), - show_zip_search = COALESCE(p_show_zip_search, brand_settings.show_zip_search), - show_schedule_pdf = COALESCE(p_show_schedule_pdf, brand_settings.show_schedule_pdf), - show_text_alerts = COALESCE(p_show_text_alerts, brand_settings.show_text_alerts), - schedule_pdf_notes = COALESCE(p_schedule_pdf_notes, brand_settings.schedule_pdf_notes), - hero_image_url = COALESCE(p_hero_image_url, brand_settings.hero_image_url), - brand_primary_color = COALESCE(p_brand_primary_color, brand_settings.brand_primary_color), - brand_secondary_color = COALESCE(p_brand_secondary_color, brand_settings.brand_secondary_color), - brand_bg_color = COALESCE(p_brand_bg_color, brand_settings.brand_bg_color), - brand_text_color = COALESCE(p_brand_text_color, brand_settings.brand_text_color), - updated_at = now() - WHERE brand_settings.brand_id = p_brand_id - RETURNING id INTO v_id; - - RETURN get_brand_settings(p_brand_id); -END; -$$; - --- Update get_brand_settings to include new color fields -CREATE OR REPLACE FUNCTION public.get_brand_settings(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_build_object( - 'id', bs.id, - 'brand_id', bs.brand_id, - 'legal_business_name', bs.legal_business_name, - 'phone', bs.phone, - 'email', bs.email, - 'website_url', bs.website_url, - 'street_address', bs.street_address, - 'city', bs.city, - 'state', bs.state, - 'postal_code', bs.postal_code, - 'country', bs.country, - 'logo_url', bs.logo_url, - 'logo_url_dark', bs.logo_url_dark, - 'default_email_signature', bs.default_email_signature, - 'invoice_footer_notes', bs.invoice_footer_notes, - 'hero_tagline', bs.hero_tagline, - 'about_headline', bs.about_headline, - 'about_subheadline', bs.about_subheadline, - 'custom_footer_text', bs.custom_footer_text, - 'show_wholesale_link', bs.show_wholesale_link, - 'show_zip_search', bs.show_zip_search, - 'show_schedule_pdf', bs.show_schedule_pdf, - 'show_text_alerts', bs.show_text_alerts, - 'schedule_pdf_notes', bs.schedule_pdf_notes, - 'hero_image_url', bs.hero_image_url, - 'brand_primary_color', bs.brand_primary_color, - 'brand_secondary_color', bs.brand_secondary_color, - 'brand_bg_color', bs.brand_bg_color, - 'brand_text_color', bs.brand_text_color, - 'updated_at', bs.updated_at, - 'brand_name', b.name - ) - INTO v_result - FROM brand_settings bs - JOIN brands b ON b.id = bs.brand_id - WHERE bs.brand_id = p_brand_id; - - RETURN COALESCE(v_result, jsonb_build_object('brand_id', p_brand_id, 'brand_name', ( - SELECT name FROM brands WHERE id = p_brand_id - ))); -END; -$$; - --- Update get_brand_settings_by_slug to include new color fields -CREATE OR REPLACE FUNCTION public.get_brand_settings_by_slug(p_brand_slug TEXT) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - result JSONB; -BEGIN - SELECT jsonb_build_object( - 'brand_id', bs.brand_id, - 'brand_name', b.name, - 'hero_tagline', bs.hero_tagline, - 'about_headline', bs.about_headline, - 'about_subheadline', bs.about_subheadline, - 'custom_footer_text', bs.custom_footer_text, - 'show_wholesale_link', bs.show_wholesale_link, - 'show_zip_search', bs.show_zip_search, - 'show_schedule_pdf', bs.show_schedule_pdf, - 'show_text_alerts', bs.show_text_alerts, - 'schedule_pdf_notes', bs.schedule_pdf_notes, - 'logo_url', bs.logo_url, - 'logo_url_dark', bs.logo_url_dark, - 'hero_image_url', bs.hero_image_url, - 'contact_email', bs.email, - 'contact_phone', bs.phone, - 'legal_business_name', bs.legal_business_name, - 'wholesale_enabled', ws.wholesale_enabled, - 'brand_primary_color', bs.brand_primary_color, - 'brand_secondary_color', bs.brand_secondary_color, - 'brand_bg_color', bs.brand_bg_color, - 'brand_text_color', bs.brand_text_color - ) - INTO result - FROM brands b - LEFT JOIN brand_settings bs ON bs.brand_id = b.id - LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id - WHERE b.slug = p_brand_slug; - - RETURN result; -END; -$$; - -COMMENT ON COLUMN brand_settings.brand_primary_color IS 'Primary accent color hex for brand storefront (buttons, links, badges, accents)'; -COMMENT ON COLUMN brand_settings.brand_secondary_color IS 'Secondary accent color hex for brand storefront'; -COMMENT ON COLUMN brand_settings.brand_bg_color IS 'Light mode page background color hex for brand storefront'; -COMMENT ON COLUMN brand_settings.brand_text_color IS 'Primary text color hex for brand storefront'; \ No newline at end of file diff --git a/supabase/migrations/.archived/095_tax_settings.sql b/supabase/migrations/.archived/095_tax_settings.sql deleted file mode 100644 index dbd4338..0000000 --- a/supabase/migrations/.archived/095_tax_settings.sql +++ /dev/null @@ -1,248 +0,0 @@ --- 095: Tax Settings — brand_settings columns + tax summary/orders RPCs --- Adds collect_sales_tax + nexus_states to brand_settings --- Creates get_tax_summary and get_taxable_orders RPCs - -BEGIN; - --- ── 1. Add columns to brand_settings ───────────────────────────────────────── -ALTER TABLE brand_settings - ADD COLUMN IF NOT EXISTS collect_sales_tax BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN IF NOT EXISTS nexus_states TEXT[] NOT NULL DEFAULT '{CO}'::TEXT[]; - --- ── 2. Update upsert_brand_settings ────────────────────────────────────────── -DROP FUNCTION IF EXISTS public.upsert_brand_settings( - UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, - TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN, BOOLEAN, BOOLEAN, - BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN, TEXT[] -); -CREATE OR REPLACE FUNCTION public.upsert_brand_settings( - p_brand_id UUID, - p_legal_business_name TEXT, - p_phone TEXT, - p_email TEXT, - p_website_url TEXT, - p_street_address TEXT, - p_city TEXT, - p_state TEXT, - p_postal_code TEXT, - p_country TEXT, - p_logo_url TEXT, - p_logo_url_dark TEXT, - p_default_email_signature TEXT, - p_invoice_footer_notes TEXT, - p_hero_tagline TEXT, - p_about_headline TEXT, - p_about_subheadline TEXT, - p_custom_footer_text TEXT, - p_show_wholesale_link BOOLEAN, - p_show_zip_search BOOLEAN, - p_show_schedule_pdf BOOLEAN, - p_show_text_alerts BOOLEAN, - p_schedule_pdf_notes TEXT, - p_hero_image_url TEXT, - p_brand_primary_color TEXT, - p_brand_secondary_color TEXT, - p_brand_bg_color TEXT, - p_brand_text_color TEXT, - p_collect_sales_tax BOOLEAN DEFAULT NULL, - p_nexus_states TEXT[] DEFAULT NULL -) RETURNS brand_settings -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_row brand_settings; -BEGIN - INSERT INTO brand_settings ( - id, brand_id, legal_business_name, phone, email, website_url, - street_address, city, state, postal_code, country, - logo_url, logo_url_dark, default_email_signature, invoice_footer_notes, - hero_tagline, about_headline, about_subheadline, custom_footer_text, - show_wholesale_link, show_zip_search, show_schedule_pdf, show_text_alerts, - schedule_pdf_notes, hero_image_url, - brand_primary_color, brand_secondary_color, brand_bg_color, brand_text_color, - collect_sales_tax, nexus_states - ) - VALUES ( - gen_random_uuid(), p_brand_id, p_legal_business_name, p_phone, p_email, p_website_url, - p_street_address, p_city, p_state, p_postal_code, p_country, - p_logo_url, p_logo_url_dark, p_default_email_signature, p_invoice_footer_notes, - p_hero_tagline, p_about_headline, p_about_subheadline, p_custom_footer_text, - COALESCE(p_show_wholesale_link, TRUE), COALESCE(p_show_zip_search, TRUE), - COALESCE(p_show_schedule_pdf, TRUE), COALESCE(p_show_text_alerts, FALSE), - p_schedule_pdf_notes, p_hero_image_url, - p_brand_primary_color, p_brand_secondary_color, p_brand_bg_color, p_brand_text_color, - COALESCE(p_collect_sales_tax, FALSE), COALESCE(p_nexus_states, '{CO}'::TEXT[]) - ) - ON CONFLICT (brand_id) DO UPDATE SET - legal_business_name = COALESCE(p_legal_business_name, brand_settings.legal_business_name), - phone = COALESCE(p_phone, brand_settings.phone), - email = COALESCE(p_email, brand_settings.email), - website_url = COALESCE(p_website_url, brand_settings.website_url), - street_address = COALESCE(p_street_address, brand_settings.street_address), - city = COALESCE(p_city, brand_settings.city), - state = COALESCE(p_state, brand_settings.state), - postal_code = COALESCE(p_postal_code, brand_settings.postal_code), - country = COALESCE(p_country, brand_settings.country), - logo_url = COALESCE(p_logo_url, brand_settings.logo_url), - logo_url_dark = COALESCE(p_logo_url_dark, brand_settings.logo_url_dark), - default_email_signature = COALESCE(p_default_email_signature, brand_settings.default_email_signature), - invoice_footer_notes = COALESCE(p_invoice_footer_notes, brand_settings.invoice_footer_notes), - hero_tagline = COALESCE(p_hero_tagline, brand_settings.hero_tagline), - about_headline = COALESCE(p_about_headline, brand_settings.about_headline), - about_subheadline = COALESCE(p_about_subheadline, brand_settings.about_subheadline), - custom_footer_text = COALESCE(p_custom_footer_text, brand_settings.custom_footer_text), - show_wholesale_link = COALESCE(p_show_wholesale_link, brand_settings.show_wholesale_link), - show_zip_search = COALESCE(p_show_zip_search, brand_settings.show_zip_search), - show_schedule_pdf = COALESCE(p_show_schedule_pdf, brand_settings.show_schedule_pdf), - show_text_alerts = COALESCE(p_show_text_alerts, brand_settings.show_text_alerts), - schedule_pdf_notes = COALESCE(p_schedule_pdf_notes, brand_settings.schedule_pdf_notes), - hero_image_url = COALESCE(p_hero_image_url, brand_settings.hero_image_url), - brand_primary_color = COALESCE(p_brand_primary_color, brand_settings.brand_primary_color), - brand_secondary_color = COALESCE(p_brand_secondary_color, brand_settings.brand_secondary_color), - brand_bg_color = COALESCE(p_brand_bg_color, brand_settings.brand_bg_color), - brand_text_color = COALESCE(p_brand_text_color, brand_settings.brand_text_color), - collect_sales_tax = COALESCE(p_collect_sales_tax, brand_settings.collect_sales_tax), - nexus_states = COALESCE(p_nexus_states, brand_settings.nexus_states) - RETURNING * INTO v_row; - RETURN v_row; -END; -$$; - --- ── 3. Update get_brand_settings ───────────────────────────────────────────── -DROP FUNCTION IF EXISTS public.get_brand_settings(uuid); -CREATE OR REPLACE FUNCTION public.get_brand_settings( - p_brand_id UUID -) RETURNS brand_settings -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_row brand_settings; -BEGIN - SELECT * - INTO v_row - FROM brand_settings - WHERE brand_id = p_brand_id; - RETURN v_row; -END; -$$; - --- ── 4. Update get_brand_settings_by_slug (public) ──────────────────────────── -DROP FUNCTION IF EXISTS public.get_brand_settings_by_slug(TEXT); -CREATE OR REPLACE FUNCTION public.get_brand_settings_by_slug( - p_brand_slug TEXT -) RETURNS brand_settings -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_row brand_settings; -BEGIN - SELECT bs.* - INTO v_row - FROM brand_settings bs - JOIN brands b ON b.id = bs.brand_id - WHERE b.slug = p_brand_slug; - RETURN v_row; -END; -$$; - --- ── 5. Tax Summary RPC ──────────────────────────────────────────────────────── -CREATE OR REPLACE FUNCTION public.get_tax_summary( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) RETURNS TABLE ( - total_tax_collected NUMERIC, - total_gross_sales NUMERIC, - order_count BIGINT, - tax_by_state JSONB -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - WITH taxed AS ( - SELECT - o.tax_amount, - o.subtotal, - o.tax_location, - -- Parse state from tax_location (format: 'STATE:CO' or just 'CO') - CASE - WHEN o.tax_location IS NOT NULL AND position('STATE:' IN o.tax_location) > 0 THEN - upper(substr(o.tax_location, position('STATE:' IN o.tax_location) + 7, 2)) - WHEN o.tax_location IS NOT NULL AND length(o.tax_location) = 2 THEN - upper(o.tax_location) - WHEN o.tax_location IS NOT NULL AND length(o.tax_location) > 2 THEN - upper(substr(o.tax_location, 1, 2)) - ELSE - 'UNKNOWN'::TEXT - END AS state_code - FROM orders o - WHERE o.brand_id = p_brand_id - AND o.created_at::DATE >= p_start_date - AND o.created_at::DATE <= p_end_date - AND o.tax_amount IS NOT NULL - AND o.tax_amount > 0 - ) - SELECT - COALESCE(SUM(t.tax_amount), 0)::NUMERIC AS total_tax_collected, - COALESCE(SUM(t.subtotal), 0)::NUMERIC AS total_gross_sales, - COUNT(DISTINCT t.tax_amount)::BIGINT AS order_count, - COALESCE(JSONB_AGG( - JSONB_BUILD_OBJECT( - 'state', state_code, - 'total_tax', COALESCE(SUM(t.tax_amount) FILTER (WHERE state_code = t.state_code), 0), - 'gross_sales', COALESCE(SUM(t.subtotal) FILTER (WHERE state_code = t.state_code), 0), - 'order_count', COUNT(*) FILTER (WHERE state_code = t.state_code) - ) ORDER BY state_code - ), '[]'::JSONB) AS tax_by_state - FROM taxed t; -END; -$$; - --- ── 6. Taxable Orders RPC ───────────────────────────────────────────────────── -CREATE OR REPLACE FUNCTION public.get_taxable_orders( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) RETURNS TABLE ( - order_id UUID, - date TEXT, - customer_name TEXT, - city TEXT, - state TEXT, - taxable_amount NUMERIC, - tax_amount NUMERIC, - tax_rate NUMERIC, - tax_location TEXT -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - o.id AS order_id, - o.created_at::DATE::TEXT AS date, - o.customer_name, - COALESCE(s.city, ''::TEXT) AS city, - COALESCE(s.state, ''::TEXT) AS state, - o.subtotal AS taxable_amount, - o.tax_amount, - o.tax_rate, - COALESCE(o.tax_location, ''::TEXT) AS tax_location - FROM orders o - LEFT JOIN stops s ON s.id = o.stop_id - WHERE o.brand_id = p_brand_id - AND o.created_at::DATE >= p_start_date - AND o.created_at::DATE <= p_end_date - AND o.tax_amount IS NOT NULL - AND o.tax_amount > 0 - ORDER BY o.created_at DESC; -END; -$$; - -COMMIT; \ No newline at end of file diff --git a/supabase/migrations/.archived/096_tax_orders_index.sql b/supabase/migrations/.archived/096_tax_orders_index.sql deleted file mode 100644 index 70568fc..0000000 --- a/supabase/migrations/.archived/096_tax_orders_index.sql +++ /dev/null @@ -1,6 +0,0 @@ --- 096: Tax Orders Index — partial index for efficient tax queries --- Note: CREATE INDEX CONCURRENTLY cannot run inside a transaction block - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_tax_brand_date - ON orders (brand_id, created_at) - WHERE tax_amount IS NOT NULL AND tax_amount > 0; \ No newline at end of file diff --git a/supabase/migrations/.archived/097_extend_create_order_with_items.sql b/supabase/migrations/.archived/097_extend_create_order_with_items.sql deleted file mode 100644 index 4ca8384..0000000 --- a/supabase/migrations/.archived/097_extend_create_order_with_items.sql +++ /dev/null @@ -1,192 +0,0 @@ --- 097: Extend create_order_with_items with tax fields --- Adds p_tax_amount, p_tax_rate, p_tax_location params to the retail order RPC - -BEGIN; - --- Get the current signature to understand the parameter list --- create_order_with_items is in migrations 001, 021, 022 --- It currently accepts: p_idempotency_key, p_customer_name, p_customer_email, --- p_customer_phone, p_stop_id, p_items - -CREATE OR REPLACE FUNCTION public.create_order_with_items( - p_idempotency_key UUID, - p_customer_name TEXT, - p_customer_email TEXT, - p_customer_phone TEXT, - p_stop_id UUID, - p_items JSONB, - p_tax_amount NUMERIC DEFAULT 0, - p_tax_rate NUMERIC DEFAULT 0, - p_tax_location TEXT DEFAULT NULL -) RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_order_id UUID; - v_item JSONB; - v_product_id UUID; - v_quantity INT; - v_fulfillment TEXT; - v_product_price NUMERIC; - v_computed_total NUMERIC := 0; - v_stop_active BOOLEAN; - v_stop_brand_id UUID; - v_stop_city TEXT; - v_stop_state TEXT; - v_stop_date TEXT; - v_stop_time TEXT; - v_stop_location TEXT; - v_product_active BOOLEAN; - v_product_brand_id UUID; - v_product_name TEXT; - v_is_pickup BOOLEAN; - v_order JSONB; - v_order_items JSONB := '[]'::JSONB; -BEGIN - -- ── Idempotency guard ────────────────────────────────────── - SELECT jsonb_build_object( - 'id', o.id, - 'customer_name', o.customer_name, - 'customer_email', o.customer_name, - 'customer_phone', o.customer_phone, - 'subtotal', o.subtotal, - 'status', o.status, - 'stop_id', o.stop_id, - 'stop_city', s.city, - 'stop_state', s.state, - 'stop_date', s.date, - 'stop_time', s.time, - 'stop_location', s.location, - 'items', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'product_id', oi.product_id, - 'product_name', p.name, - 'quantity', oi.quantity, - 'price', oi.price, - 'fulfillment', oi.fulfillment - )) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id - ), '[]'::JSONB) - ) - INTO v_order - FROM orders o - JOIN stops s ON o.stop_id = s.id - WHERE o.idempotency_key = p_idempotency_key - LIMIT 1; - - IF v_order IS NOT NULL THEN - RETURN v_order; - END IF; - - -- ── Validate stop ────────────────────────────────────────── - SELECT active, brand_id, city, state, date, time, location - INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location - FROM stops - WHERE id = p_stop_id; - - IF p_stop_id IS NOT NULL AND (v_stop_active IS NULL OR v_stop_active IS FALSE) THEN - RAISE EXCEPTION 'Stop not found or inactive'; - END IF; - - -- ── Validate products & build items list ─────────────────── - FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) - LOOP - v_product_id := (v_item->>'id')::UUID; - v_quantity := (v_item->>'quantity')::INT; - v_fulfillment := COALESCE(v_item->>'fulfillment', 'pickup'); - - SELECT active, brand_id, name, price - INTO v_product_active, v_product_brand_id, v_product_name, v_product_price - FROM products - WHERE id = v_product_id; - - IF v_product_active IS FALSE THEN - RAISE EXCEPTION 'Product % is not active', v_product_id; - END IF; - - v_computed_total := v_computed_total + (v_product_price * v_quantity); - - v_order_items := v_order_items || jsonb_build_object( - 'product_id', v_product_id, - 'product_name', v_product_name, - 'quantity', v_quantity, - 'price', v_product_price, - 'fulfillment', v_fulfillment - ); - END LOOP; - - -- ── Determine brand ──────────────────────────────────────── - -- Use stop's brand if stop_id set, otherwise require brand from products - IF p_stop_id IS NOT NULL THEN - -- brand from stop verified above - NULL; - ELSE - -- shipping-only: brand must come from first product - v_product_brand_id := v_product_brand_id; -- already selected in loop - END IF; - - v_is_pickup := v_fulfillment = 'pickup'; - - -- ── Create order ──────────────────────────────────────────── - INSERT INTO orders ( - idempotency_key, customer_name, customer_email, customer_phone, - stop_id, subtotal, status, - brand_id, - -- Tax fields - tax_amount, tax_rate, tax_location, - pickup_complete - ) VALUES ( - p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone, - p_stop_id, v_computed_total, 'pending', - v_stop_brand_id, - -- Tax fields - p_tax_amount, p_tax_rate, p_tax_location, - FALSE - ) - RETURNING id INTO v_order_id; - - -- ── Insert order items ────────────────────────────────────── - FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) - LOOP - v_product_id := (v_item->>'id')::UUID; - v_quantity := (v_item->>'quantity')::INT; - v_fulfillment := COALESCE(v_item->>'fulfillment', 'pickup'); - - SELECT price INTO v_product_price FROM products WHERE id = v_product_id; - - INSERT INTO order_items (order_id, product_id, quantity, price, fulfillment) - VALUES (v_order_id, v_product_id, v_quantity, v_product_price, v_fulfillment); - END LOOP; - - -- ── Emit order_placed event ──────────────────────────────── - PERFORM pg_notify('order_placed', jsonb_build_object( - 'order_id', v_order_id, - 'brand_id', v_stop_brand_id, - 'customer_name', p_customer_name, - 'subtotal', v_computed_total, - 'fulfillment', CASE WHEN p_stop_id IS NOT NULL THEN 'pickup' ELSE 'ship' END - )::TEXT); - - -- ── Return order ──────────────────────────────────────────── - RETURN jsonb_build_object( - 'id', v_order_id, - 'customer_name', p_customer_name, - 'customer_email', p_customer_email, - 'customer_phone', p_customer_phone, - 'subtotal', v_computed_total, - 'status', 'pending', - 'stop_id', p_stop_id, - 'stop_city', v_stop_city, - 'stop_state', v_stop_state, - 'stop_date', v_stop_date, - 'stop_time', v_stop_time, - 'stop_location', v_stop_location, - 'items', v_order_items - ); -END; -$$; - -COMMIT; \ No newline at end of file diff --git a/supabase/migrations/.archived/098_products_is_taxable.sql b/supabase/migrations/.archived/098_products_is_taxable.sql deleted file mode 100644 index 189eecf..0000000 --- a/supabase/migrations/.archived/098_products_is_taxable.sql +++ /dev/null @@ -1,53 +0,0 @@ --- 098_products_is_taxable.sql --- Add is_taxable flag to products table - -ALTER TABLE products ADD COLUMN IF NOT EXISTS is_taxable BOOLEAN NOT NULL DEFAULT true; - --- Comment for documentation -COMMENT ON COLUMN products.is_taxable IS 'If false, this product is exempt from sales tax even when shipping to nexus states'; - --- Update get_stop_products to include is_taxable -DROP FUNCTION IF EXISTS public.get_stop_products(UUID); -CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID) -RETURNS TABLE ( - id UUID, - name TEXT, - description TEXT, - price NUMERIC, - type TEXT, - image_url TEXT, - active BOOLEAN, - brand_id UUID, - is_taxable BOOLEAN -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - p.id, - p.name, - p.description, - p.price, - p.type, - p.image_url, - p.active, - p.brand_id, - COALESCE(p.is_taxable, true)::BOOLEAN AS is_taxable - FROM products p - WHERE p.brand_id = ( - SELECT stop_id::UUID FROM ( - SELECT brand_id AS stop_id FROM stops WHERE id = p_stop_id - UNION ALL - SELECT brand_id FROM stops WHERE id = p_stop_id - ) AS sub LIMIT 1 - ) - AND p.active = true - AND p.deleted_at IS NULL - ORDER BY p.name; -END; -$$; - --- Create index on is_taxable for common queries -CREATE INDEX IF NOT EXISTS idx_products_is_taxable ON products(is_taxable) WHERE is_taxable = false; \ No newline at end of file diff --git a/supabase/migrations/.archived/099_contact_imports_bucket.sql b/supabase/migrations/.archived/099_contact_imports_bucket.sql deleted file mode 100644 index b5e6ab2..0000000 --- a/supabase/migrations/.archived/099_contact_imports_bucket.sql +++ /dev/null @@ -1,36 +0,0 @@ --- Create imports bucket if it doesn't exist --- Note: Run this in Supabase dashboard or via CLI --- supabase storage create contacts-imports --public - --- Create RPC function to process imports from bucket URL -CREATE OR REPLACE FUNCTION process_contact_import_from_url( - p_brand_id UUID, - p_file_url TEXT, - p_allow_opt_in_override BOOLEAN DEFAULT false -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_result JSONB; - v_csv_text TEXT; -BEGIN - -- Fetch the CSV from the bucket URL - SELECT content INTO v_csv_text - FROM net.http_get(p_file_url); - - -- Process the contacts (this would need to be implemented based on your CSV parsing logic) - -- For now, return a placeholder result - -- You would call your existing import logic here - - v_result := jsonb_build_object( - 'created', 0, - 'updated', 0, - 'skipped', 0, - 'errors', 0 - ); - - RETURN v_result; -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/099_harvest_reach_segmentation.sql b/supabase/migrations/.archived/099_harvest_reach_segmentation.sql deleted file mode 100644 index c1a1504..0000000 --- a/supabase/migrations/.archived/099_harvest_reach_segmentation.sql +++ /dev/null @@ -1,554 +0,0 @@ --- ============================================================ --- 099_harvest_reach_segmentation.sql --- Advanced segmentation: product filter, multi-filter AND/OR, --- stop picker, campaign analytics, scheduled campaign sender --- ============================================================ - --- Helper: get all customer IDs matching a single filter block -CREATE OR REPLACE FUNCTION get_customer_ids_for_filter( - p_brand_id UUID, - p_filter_type TEXT, - p_filter_params JSONB -) -RETURNS TABLE (customer_id UUID) -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_days_back INTEGER := COALESCE((p_filter_params->>'days_back')::int, 90); - v_date_from TEXT := p_filter_params->>'date_from'; - v_date_to TEXT := p_filter_params->>'date_to'; - v_stop_id UUID := NULLIF(p_filter_params->>'stop_id', '')::UUID; - v_product_id UUID := NULLIF(p_filter_params->>'product_id', '')::UUID; - v_zip_codes TEXT[] := NULL; - v_city TEXT := p_filter_params->>'city'; - v_order_hist TEXT := COALESCE(p_filter_params->>'order_history', 'all'); - v_tags TEXT[] := NULL; -BEGIN - -- Parse optional zip_codes array - IF p_filter_params ? 'zip_codes' AND jsonb_typeof(p_filter_params->'zip_codes') = 'array' THEN - v_zip_codes := ARRAY(SELECT jsonb_array_elements_text(p_filter_params->'zip_codes')); - END IF; - - -- Parse optional tags array - IF p_filter_params ? 'tags' AND jsonb_typeof(p_filter_params->'tags') = 'array' THEN - v_tags := ARRAY(SELECT jsonb_array_elements_text(p_filter_params->'tags')); - END IF; - - CASE p_filter_type - WHEN 'all_customers' THEN - RETURN QUERY - SELECT DISTINCT c.id FROM customers c WHERE c.brand_id = p_brand_id; - - WHEN 'stop' THEN - RETURN QUERY - SELECT DISTINCT o.customer_id - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE o.brand_id = p_brand_id - AND o.status NOT IN ('canceled') - AND (v_stop_id IS NULL OR s.id = v_stop_id) - AND (v_date_from IS NULL OR s.date >= v_date_from) - AND (v_date_to IS NULL OR s.date <= v_date_to); - - WHEN 'upcoming_stop' THEN - RETURN QUERY - SELECT DISTINCT o.customer_id - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE o.brand_id = p_brand_id - AND o.status NOT IN ('canceled') - AND s.date >= CURRENT_DATE::TEXT - AND (v_stop_id IS NULL OR s.id = v_stop_id); - - WHEN 'product' THEN - RETURN QUERY - SELECT DISTINCT oi.order_id - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE o.brand_id = p_brand_id - AND oi.product_id = v_product_id - AND o.created_at >= NOW() - (v_days_back || ' days')::INTERVAL - AND o.status NOT IN ('canceled'); - - WHEN 'zip_code' THEN - RETURN QUERY - SELECT DISTINCT o.customer_id - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE o.brand_id = p_brand_id - AND o.status NOT IN ('canceled') - AND (v_zip_codes IS NULL OR s.zip = ANY(v_zip_codes)) - AND (v_city IS NULL OR s.city ILIKE v_city); - - WHEN 'customer_history' THEN - RETURN QUERY - SELECT oc.customer_id - FROM ( - SELECT o.customer_id, - COUNT(*) AS order_count, - MIN(o.created_at) AS first_order_at - FROM orders o - WHERE o.brand_id = p_brand_id - AND o.created_at >= NOW() - (v_days_back || ' days')::INTERVAL - AND o.status NOT IN ('canceled') - GROUP BY o.customer_id - ) oc - WHERE CASE v_order_hist - WHEN 'first_order' THEN oc.order_count = 1 - WHEN 'repeat' THEN oc.order_count > 1 - ELSE true - END; - - WHEN 'tags' THEN - RETURN QUERY - SELECT DISTINCT cc.customer_id - FROM communication_contacts cc - WHERE cc.brand_id = p_brand_id - AND cc.tags && v_tags; - - ELSE - -- Unknown filter type: return empty - RETURN; - END CASE; -END; -$$; - --- ============================================================ --- preview_campaign_audience_v2 --- Supports single-target (legacy) and multi-filter SegmentRuleV2 --- { combinator: "AND"|"OR", filters: [{ type, params }] } --- For legacy single-target: { target, ... } (backward compat) --- Returns { count, sample_customers: [...] } --- ============================================================ -CREATE OR REPLACE FUNCTION preview_campaign_audience( - p_brand_id UUID, - p_audience_rules JSONB -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_target TEXT; - v_count BIGINT; - v_customers JSONB; - v_combinator TEXT := 'AND'; - v_filters JSONB; - v_customer_ids UUID[]; - v_set UUID[]; - v_first BOOLEAN := TRUE; - v_filter JSONB; - v_filter_type TEXT; - v_filter_params JSONB; -BEGIN - v_target := p_audience_rules->>'target'; - - -- Multi-filter SegmentRuleV2 format - IF p_audience_rules ? 'filters' THEN - v_combinator := COALESCE(p_audience_rules->>'combinator', 'AND'); - v_filters := p_audience_rules->'filters'; - - FOR v_filter IN SELECT * FROM jsonb_array_elements(v_filters) - LOOP - v_filter_type := v_filter->>'type'; - v_filter_params := COALESCE(v_filter->'params', '{}'::jsonb); - - -- Collect matching customer IDs for this filter - IF v_combinator = 'AND' THEN - -- INTERSECTION: only keep customers who match this AND all previous - IF v_first THEN - v_set := ARRAY(SELECT customer_id FROM get_customer_ids_for_filter(p_brand_id, v_filter_type, v_filter_params)); - v_first := FALSE; - ELSE - v_set := ARRAY( - SELECT unnest - FROM unnest(v_set) AS unnest - WHERE unnest = ANY(ARRAY(SELECT customer_id FROM get_customer_ids_for_filter(p_brand_id, v_filter_type, v_filter_params))) - ); - END IF; - ELSE - -- UNION: accumulate all unique customers - v_set := array_distinct(v_set || ARRAY(SELECT customer_id FROM get_customer_ids_for_filter(p_brand_id, v_filter_type, v_filter_params))); - v_first := FALSE; - END IF; - END LOOP; - - -- Build result from accumulated set - SELECT COUNT(*), - COALESCE(jsonb_agg(sub), '[]'::jsonb) - INTO v_count, v_customers - FROM ( - SELECT jsonb_build_object( - 'id', c.id, - 'email', COALESCE(cc.email, c.primary_email), - 'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email), - 'tags', COALESCE(cc.tags, '{}'::text[]), - 'phone', cc.phone - ) AS sub - FROM unnest(v_set) AS uid(customer_id) - JOIN customers c ON c.id = uid.customer_id - LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id - LIMIT 20 - ) sub; - - RETURN jsonb_build_object('count', v_count, 'sample_customers', v_customers); - END IF; - - -- Legacy single-target format (backward compat) - CASE v_target - WHEN 'product' THEN - WITH matching AS ( - SELECT DISTINCT o.customer_id - FROM orders o - JOIN order_items oi ON oi.order_id = o.id - WHERE o.brand_id = p_brand_id - AND oi.product_id = NULLIF(p_audience_rules->>'product_id', '')::UUID - AND o.created_at >= NOW() - (COALESCE((p_audience_rules->>'days_back')::int, 90) || ' days')::INTERVAL - AND o.status NOT IN ('canceled') - ) - SELECT COUNT(*) INTO v_count FROM matching; - SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers - FROM ( - SELECT jsonb_build_object( - 'id', c.id, - 'email', COALESCE(cc.email, c.primary_email), - 'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email), - 'tags', COALESCE(cc.tags, '{}'::text[]), - 'phone', cc.phone - ) AS sub - FROM (SELECT DISTINCT customer_id FROM matching) m - JOIN customers c ON c.id = m.customer_id - LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id - LIMIT 20 - ) sub; - - WHEN 'upcoming_stop' THEN - WITH matching AS ( - SELECT DISTINCT o.customer_id - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE o.brand_id = p_brand_id - AND s.date >= CURRENT_DATE::TEXT - AND o.status NOT IN ('canceled') - AND (NULLIF(p_audience_rules->>'stop_id', '') IS NULL OR s.id = NULLIF(p_audience_rules->>'stop_id', '')::UUID) - ) - SELECT COUNT(*) INTO v_count FROM matching; - SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers - FROM ( - SELECT jsonb_build_object( - 'id', c.id, - 'email', COALESCE(cc.email, c.primary_email), - 'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email), - 'tags', COALESCE(cc.tags, '{}'::text[]), - 'phone', cc.phone - ) AS sub - FROM (SELECT DISTINCT customer_id FROM matching) m - JOIN customers c ON c.id = m.customer_id - LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id - LIMIT 20 - ) sub; - - WHEN 'stop' THEN - WITH matching AS ( - SELECT DISTINCT o.customer_id - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE o.brand_id = p_brand_id - AND (NULLIF(p_audience_rules->>'stop_id', '') IS NULL OR s.id = NULLIF(p_audience_rules->>'stop_id', '')::UUID) - AND (NULLIF(p_audience_rules->>'date_from', '') IS NULL OR s.date >= p_audience_rules->>'date_from') - AND (NULLIF(p_audience_rules->>'date_to', '') IS NULL OR s.date <= p_audience_rules->>'date_to') - AND o.status NOT IN ('canceled') - ) - SELECT COUNT(*) INTO v_count FROM matching; - SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers - FROM ( - SELECT jsonb_build_object( - 'id', c.id, - 'email', COALESCE(cc.email, c.primary_email), - 'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email), - 'tags', COALESCE(cc.tags, '{}'::text[]), - 'phone', cc.phone - ) AS sub - FROM (SELECT DISTINCT customer_id FROM matching) m - JOIN customers c ON c.id = m.customer_id - LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id - LIMIT 20 - ) sub; - - WHEN 'zip_code' THEN - DECLARE - v_zips TEXT[] := ARRAY(SELECT jsonb_array_elements_text(p_audience_rules->'zip_codes')); - v_city TEXT := p_audience_rules->>'city'; - BEGIN - WITH matching AS ( - SELECT DISTINCT o.customer_id - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE o.brand_id = p_brand_id - AND (v_zips IS NULL OR s.zip = ANY(v_zips)) - AND (v_city IS NULL OR s.city ILIKE v_city) - AND o.status NOT IN ('canceled') - ) - SELECT COUNT(*) INTO v_count FROM matching; - SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers - FROM ( - SELECT jsonb_build_object( - 'id', c.id, - 'email', COALESCE(cc.email, c.primary_email), - 'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email), - 'tags', COALESCE(cc.tags, '{}'::text[]), - 'phone', cc.phone - ) AS sub - FROM (SELECT DISTINCT customer_id FROM matching) m - JOIN customers c ON c.id = m.customer_id - LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id - LIMIT 20 - ) sub; - END; - - WHEN 'customer_history' THEN - DECLARE - v_hist TEXT := COALESCE(p_audience_rules->>'order_history', 'all'); - v_days INT := COALESCE((p_audience_rules->>'days_back')::int, 90); - BEGIN - WITH order_counts AS ( - SELECT o.customer_id, COUNT(*) AS order_count - FROM orders o - WHERE o.brand_id = p_brand_id - AND o.created_at >= NOW() - (v_days || ' days')::INTERVAL - AND o.status NOT IN ('canceled') - GROUP BY o.customer_id - ), - filtered AS ( - SELECT customer_id FROM order_counts oc - WHERE CASE v_hist - WHEN 'first_order' THEN oc.order_count = 1 - WHEN 'repeat' THEN oc.order_count > 1 - ELSE true END - ) - SELECT COUNT(*) INTO v_count FROM filtered; - SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers - FROM ( - SELECT jsonb_build_object( - 'id', c.id, - 'email', COALESCE(cc.email, c.primary_email), - 'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email), - 'tags', COALESCE(cc.tags, '{}'::text[]), - 'phone', cc.phone - ) AS sub - FROM filtered f - JOIN customers c ON c.id = f.customer_id - LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id - LIMIT 20 - ) sub; - END; - - WHEN 'customer_ids' THEN - DECLARE - v_ids UUID[] := ARRAY(SELECT NULLIF(trim(value), '')::UUID FROM jsonb_array_elements_text(p_audience_rules->'customer_ids')); - BEGIN - SELECT COUNT(*) INTO v_count - FROM unnest(v_ids) AS uid(id) - WHERE uid.id IN (SELECT id FROM customers WHERE brand_id = p_brand_id); - SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers - FROM ( - SELECT jsonb_build_object( - 'id', c.id, - 'email', COALESCE(cc.email, c.primary_email), - 'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email), - 'tags', COALESCE(cc.tags, '{}'::text[]), - 'phone', cc.phone - ) AS sub - FROM unnest(v_ids) AS uid(id) - JOIN customers c ON c.id = uid.id - LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id - LIMIT 20 - ) sub; - END; - - ELSE -- 'all_customers' or unknown - SELECT COUNT(*) INTO v_count FROM customers WHERE brand_id = p_brand_id; - SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers - FROM ( - SELECT jsonb_build_object( - 'id', c.id, - 'email', COALESCE(cc.email, c.primary_email), - 'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email), - 'tags', COALESCE(cc.tags, '{}'::text[]), - 'phone', cc.phone - ) AS sub - FROM customers c - LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id - WHERE c.brand_id = p_brand_id - LIMIT 20 - ) sub; - END CASE; - - RETURN jsonb_build_object('count', v_count, 'sample_customers', v_customers); -END; -$$; - --- ============================================================ --- get_products_for_segment_picker --- Returns active products for product-filter dropdown --- ============================================================ -CREATE OR REPLACE FUNCTION get_products_for_segment_picker(p_brand_id UUID) -RETURNS TABLE (id UUID, name TEXT, type TEXT, price NUMERIC) -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN QUERY - SELECT p.id, p.name, p.type, p.price - FROM products p - WHERE p.brand_id = p_brand_id - AND p.deleted_at IS NULL - AND p.active = true - ORDER BY p.name ASC; -END; -$$; - --- ============================================================ --- get_stops_for_segment_picker --- Returns stops with is_upcoming / is_past flags --- ============================================================ -CREATE OR REPLACE FUNCTION get_stops_for_segment_picker( - p_brand_id UUID, - p_stop_id UUID DEFAULT NULL -) -RETURNS TABLE ( - id UUID, - city TEXT, - state TEXT, - date TEXT, - time TEXT, - location TEXT, - zip TEXT, - is_upcoming BOOLEAN, - is_past BOOLEAN -) -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_today TEXT := CURRENT_DATE::TEXT; -BEGIN - RETURN QUERY - SELECT - s.id, - s.city, - s.state, - s.date, - s.time, - s.location, - s.zip, - (s.date >= v_today) AS is_upcoming, - (s.date < v_today) AS is_past - FROM stops s - WHERE s.brand_id = p_brand_id - AND s.deleted_at IS NULL - AND s.active = true - AND s.status = 'active' - AND (p_stop_id IS NULL OR s.id = p_stop_id) - ORDER BY s.date DESC; -END; -$$; - --- ============================================================ --- get_campaign_analytics --- Aggregates message logs into campaign engagement stats --- ============================================================ -CREATE OR REPLACE FUNCTION get_campaign_analytics( - p_brand_id UUID, - p_campaign_id UUID DEFAULT NULL -) -RETURNS TABLE ( - campaign_id UUID, - campaign_name TEXT, - total_sent BIGINT, - total_delivered BIGINT, - total_opened BIGINT, - total_clicked BIGINT, - total_bounced BIGINT, - delivered_rate NUMERIC, - open_rate NUMERIC, - click_rate NUMERIC, - bounce_rate NUMERIC, - sent_at TIMESTAMPTZ -) -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN QUERY - SELECT - cc.id AS campaign_id, - cc.name AS campaign_name, - COUNT(CASE WHEN ml.status != 'queued' THEN 1 END)::BIGINT AS total_sent, - COUNT(CASE WHEN ml.delivered_at IS NOT NULL THEN 1 END)::BIGINT AS total_delivered, - COUNT(CASE WHEN ml.opened_at IS NOT NULL THEN 1 END)::BIGINT AS total_opened, - COUNT(CASE WHEN ml.clicked_at IS NOT NULL THEN 1 END)::BIGINT AS total_clicked, - COUNT(CASE WHEN ml.bounced_at IS NOT NULL THEN 1 END)::BIGINT AS total_bounced, - - CASE WHEN COUNT(CASE WHEN ml.status != 'queued' THEN 1 END) > 0 - THEN ROUND(100.0 - * COUNT(CASE WHEN ml.delivered_at IS NOT NULL THEN 1 END) - / COUNT(CASE WHEN ml.status != 'queued' THEN 1 END), 1) - ELSE 0 END AS delivered_rate, - - CASE WHEN COUNT(CASE WHEN ml.delivered_at IS NOT NULL THEN 1 END) > 0 - THEN ROUND(100.0 - * COUNT(CASE WHEN ml.opened_at IS NOT NULL THEN 1 END) - / COUNT(CASE WHEN ml.delivered_at IS NOT NULL THEN 1 END), 1) - ELSE 0 END AS open_rate, - - CASE WHEN COUNT(CASE WHEN ml.delivered_at IS NOT NULL THEN 1 END) > 0 - THEN ROUND(100.0 - * COUNT(CASE WHEN ml.clicked_at IS NOT NULL THEN 1 END) - / COUNT(CASE WHEN ml.delivered_at IS NOT NULL THEN 1 END), 1) - ELSE 0 END AS click_rate, - - CASE WHEN COUNT(CASE WHEN ml.status != 'queued' THEN 1 END) > 0 - THEN ROUND(100.0 - * COUNT(CASE WHEN ml.bounced_at IS NOT NULL THEN 1 END) - / COUNT(CASE WHEN ml.status != 'queued' THEN 1 END), 1) - ELSE 0 END AS bounce_rate, - - cc.sent_at - FROM communication_campaigns cc - LEFT JOIN communication_message_logs ml ON ml.campaign_id = cc.id - WHERE cc.brand_id = p_brand_id - AND (p_campaign_id IS NULL OR cc.id = p_campaign_id) - AND cc.status = 'sent' - GROUP BY cc.id, cc.name, cc.sent_at - ORDER BY cc.sent_at DESC NULLS LAST; -END; -$$; - --- ============================================================ --- send_scheduled_campaigns --- Called by cron job: sends all overdue scheduled campaigns --- ============================================================ -CREATE OR REPLACE FUNCTION send_scheduled_campaigns() -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_campaign communication_campaigns; - v_result JSONB := '[]'::JSONB; - v_send_result JSONB; -BEGIN - FOR v_campaign IN - SELECT * FROM communication_campaigns - WHERE status = 'scheduled' - AND scheduled_at <= NOW() - LOOP - SELECT send_campaign(v_campaign.id) INTO v_send_result; - v_result := v_result || jsonb_build_array( - jsonb_build_object( - 'campaign_id', v_campaign.id, - 'campaign_name', v_campaign.name, - 'result', v_send_result - ) - ); - END LOOP; - - RETURN jsonb_build_object('results', v_result); -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/108_lot_quantity_tracking.sql b/supabase/migrations/.archived/108_lot_quantity_tracking.sql deleted file mode 100644 index de483e2..0000000 --- a/supabase/migrations/.archived/108_lot_quantity_tracking.sql +++ /dev/null @@ -1,77 +0,0 @@ --- 108_lot_quantity_tracking.sql --- Add quantity_used_lbs tracking to harvest_lots + wire get_lot_orders + mark_lot_used_in_order - -ALTER TABLE harvest_lots ADD COLUMN IF NOT EXISTS quantity_used_lbs NUMERIC(12,2) DEFAULT 0 NOT NULL; - -DROP FUNCTION IF EXISTS public.get_lot_orders(uuid); -DROP FUNCTION IF EXISTS public.mark_lot_used_in_order(uuid, uuid, text, uuid); - -CREATE OR REPLACE FUNCTION public.get_lot_orders(p_lot_id uuid) -RETURNS TABLE( - id uuid, - customer_name text, - order_date text, - stop_name text, - item_quantity numeric, - item_notes text, - fulfillment text, - lot_quantity_used numeric -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $function$ -BEGIN - RETURN QUERY - SELECT - o.id, - COALESCE(c.name, c.email) as customer_name, - TO_CHAR(o.created_at, 'MM/DD/YYYY') as order_date, - s.name as stop_name, - oi.quantity::NUMERIC as item_quantity, - oi.notes as item_notes, - oi.fulfillment, - oi.quantity::NUMERIC as lot_quantity_used - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - LEFT JOIN customers c ON c.id = o.customer_id - LEFT JOIN stops s ON s.id = o.stop_id - WHERE oi.lot_id = p_lot_id - OR oi.lot_number = (SELECT lot_number FROM harvest_lots WHERE id = p_lot_id); -END; -$function$; - -CREATE OR REPLACE FUNCTION public.mark_lot_used_in_order( - p_lot_id uuid, - p_order_id uuid, - p_quantity_to_add numeric DEFAULT NULL, - p_notes text DEFAULT NULL::text, - p_admin_id uuid DEFAULT NULL::uuid -) -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -AS $function$ -DECLARE - v_lot_number text; - v_qty numeric; -BEGIN - SELECT lot_number INTO v_lot_number FROM harvest_lots WHERE id = p_lot_id; - - IF p_quantity_to_add IS NOT NULL AND p_quantity_to_add > 0 THEN - v_qty := p_quantity_to_add; - ELSE - SELECT COALESCE(SUM(oi.quantity), 0) INTO v_qty - FROM order_items oi - WHERE oi.lot_id = p_lot_id OR oi.lot_number = v_lot_number; - END IF; - - UPDATE harvest_lots - SET quantity_used_lbs = COALESCE(quantity_used_lbs, 0) + v_qty - WHERE id = p_lot_id; - - INSERT INTO harvest_lot_events (lot_id, event_type, event_time, notes, created_by) - VALUES (p_lot_id, 'marked_used', NOW(), p_notes, p_admin_id); - - RETURN TRUE; -END; -$function$; \ No newline at end of file diff --git a/supabase/migrations/.archived/109_enable_rls_critical.sql b/supabase/migrations/.archived/109_enable_rls_critical.sql deleted file mode 100644 index db78177..0000000 --- a/supabase/migrations/.archived/109_enable_rls_critical.sql +++ /dev/null @@ -1,160 +0,0 @@ --- 109_enable_rls_critical.sql (v2) --- Supabase flagged 3 tables with RLS disabled. This replaces earlier policies --- that may have been created by Supabase's built-in RLS assistant. --- --- Confirmed existing (legacy) policies dropped: --- "Platform admins manage admin_users" — no active=true check --- "admin_users self read" — only self (user_id=auth.uid()), skips brand scope --- --- Replacement policies: --- admin_users_platform_admin_all — platform_admin with active=true only --- admin_users_brand_scoped — brand scoped ALL (brand_admin/staff) --- harvest_lots_platform_admin_all— platform_admin with active=true only --- harvest_lots_brand_scoped — brand scoped ALL --- harvest_lot_events_platform_admin_all — platform_admin with active=true only --- harvest_lot_events_brand_scoped — events scoped via lot → harvest_lots - -BEGIN; - --- ── admin_users ──────────────────────────────────────────────────────────────── - -ALTER TABLE public.admin_users ENABLE ROW LEVEL SECURITY; - --- Drop legacy policies installed by Supabase assistant -DROP POLICY IF EXISTS "Platform admins manage admin_users" ON public.admin_users; -DROP POLICY IF EXISTS "admin_users self read" ON public.admin_users; -DROP POLICY IF EXISTS "admin_users_platform_admin_all" ON public.admin_users; -DROP POLICY IF EXISTS "admin_users_brand_scoped" ON public.admin_users; - --- Platform admins: full access (active=true required) -CREATE POLICY "admin_users_platform_admin_all" ON public.admin_users - FOR ALL TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ); - --- Brand admins / staff: access only rows matching their brand -CREATE POLICY "admin_users_brand_scoped" ON public.admin_users - FOR ALL TO authenticated - USING ( - brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ) - WITH CHECK ( - brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ); - --- ── harvest_lots ─────────────────────────────────────────────────────────────── - -ALTER TABLE public.harvest_lots ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "harvest_lots_platform_admin_all" ON public.harvest_lots; -DROP POLICY IF EXISTS "harvest_lots_brand_scoped" ON public.harvest_lots; - -CREATE POLICY "harvest_lots_platform_admin_all" ON public.harvest_lots - FOR ALL TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ); - -CREATE POLICY "harvest_lots_brand_scoped" ON public.harvest_lots - FOR ALL TO authenticated - USING ( - brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ) - WITH CHECK ( - brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ); - --- ── harvest_lot_events ──────────────────────────────────────────────────────── - -ALTER TABLE public.harvest_lot_events ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "harvest_lot_events_platform_admin_all" ON public.harvest_lot_events; -DROP POLICY IF EXISTS "harvest_lot_events_brand_scoped" ON public.harvest_lot_events; - -CREATE POLICY "harvest_lot_events_platform_admin_all" ON public.harvest_lot_events - FOR ALL TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ); - --- Events are scoped to the lot via lot_id, so we check the lot's brand_id -CREATE POLICY "harvest_lot_events_brand_scoped" ON public.harvest_lot_events - FOR ALL TO authenticated - USING ( - lot_id IN ( - SELECT hl.id FROM public.harvest_lots hl - WHERE hl.brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ) - ) - WITH CHECK ( - lot_id IN ( - SELECT hl.id FROM public.harvest_lots hl - WHERE hl.brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ) - ); - -COMMIT; \ No newline at end of file diff --git a/supabase/migrations/.archived/110_rls_zero_policy_tables.sql b/supabase/migrations/.archived/110_rls_zero_policy_tables.sql deleted file mode 100644 index d63c63a..0000000 --- a/supabase/migrations/.archived/110_rls_zero_policy_tables.sql +++ /dev/null @@ -1,303 +0,0 @@ --- 110_rls_zero_policy_tables.sql --- Tables with RLS enabled but 0 policies — these allow ALL authenticated --- users unrestricted access to all rows. We add brand-scoped policies. --- --- Also includes brand_features which is referenced by the feature-flag --- system and the ADDON_CATALOG gating. --- --- All writes go through SECURITY DEFINER RPCs (bypass RLS). These policies --- are defence-in-depth for direct table access only. - -BEGIN; - --- ── brand_features ──────────────────────────────────────────────────────────── --- brand_features has no policies — any authenticated user can read/modify --- any brand's feature flags. This is a security critical gap because --- feature flags control paid add-ons and access to modules. - -ALTER TABLE public.brand_features ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "brand_features_platform_admin_all" ON public.brand_features; -DROP POLICY IF EXISTS "brand_features_brand_scoped" ON public.brand_features; - --- Platform admins: full access -CREATE POLICY "brand_features_platform_admin_all" ON public.brand_features - FOR ALL TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ); - --- Brand admins/staff: only their brand's feature rows -CREATE POLICY "brand_features_brand_scoped" ON public.brand_features - FOR ALL TO authenticated - USING ( - brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ) - WITH CHECK ( - brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ); - --- ── communication_segments ─────────────────────────────────────────────────── --- Audience segments for Harvest Reach campaigns. - -ALTER TABLE public.communication_segments ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "communication_segments_platform_admin_all" ON public.communication_segments; -DROP POLICY IF EXISTS "communication_segments_brand_scoped" ON public.communication_segments; - -CREATE POLICY "communication_segments_platform_admin_all" ON public.communication_segments - FOR ALL TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ); - -CREATE POLICY "communication_segments_brand_scoped" ON public.communication_segments - FOR ALL TO authenticated - USING ( - brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ) - WITH CHECK ( - brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ); - --- ── messages ────────────────────────────────────────────────────────────────── --- Stop-level broadcast messages. 0 policies = any user can read/write all. - -ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "messages_platform_admin_all" ON public.messages; -DROP POLICY IF EXISTS "messages_brand_scoped" ON public.messages; - -CREATE POLICY "messages_platform_admin_all" ON public.messages - FOR ALL TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ); - --- Scoped via stop → brand relationship -CREATE POLICY "messages_brand_scoped" ON public.messages - FOR ALL TO authenticated - USING ( - stop_id IN ( - SELECT s.id FROM public.stops s - WHERE s.brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ) - ) - WITH CHECK ( - stop_id IN ( - SELECT s.id FROM public.stops s - WHERE s.brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ) - ); - --- ── message_recipients ──────────────────────────────────────────────────────── --- Recipients for broadcast messages. Also 0 policies. - -ALTER TABLE public.message_recipients ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "message_recipients_platform_admin_all" ON public.message_recipients; -DROP POLICY IF EXISTS "message_recipients_brand_scoped" ON public.message_recipients; - -CREATE POLICY "message_recipients_platform_admin_all" ON public.message_recipients - FOR ALL TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ); - -CREATE POLICY "message_recipients_brand_scoped" ON public.message_recipients - FOR ALL TO authenticated - USING ( - message_id IN ( - SELECT m.id FROM public.messages m - WHERE m.stop_id IN ( - SELECT s.id FROM public.stops s - WHERE s.brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ) - ) - ) - WITH CHECK ( - message_id IN ( - SELECT m.id FROM public.messages m - WHERE m.stop_id IN ( - SELECT s.id FROM public.stops s - WHERE s.brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ) - ) - ); - --- ── stop_products ───────────────────────────────────────────────────────────── --- Junction table for products assigned to stops. 0 policies. - -ALTER TABLE public.stop_products ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "stop_products_platform_admin_all" ON public.stop_products; -DROP POLICY IF EXISTS "stop_products_brand_scoped" ON public.stop_products; - -CREATE POLICY "stop_products_platform_admin_all" ON public.stop_products - FOR ALL TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ); - --- Scoped via stop → brand -CREATE POLICY "stop_products_brand_scoped" ON public.stop_products - FOR ALL TO authenticated - USING ( - stop_id IN ( - SELECT s.id FROM public.stops s - WHERE s.brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ) - ) - WITH CHECK ( - stop_id IN ( - SELECT s.id FROM public.stops s - WHERE s.brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - ) - ) - ); - --- ── square_sync_queue ────────────────────────────────────────────────────────── --- Internal queue for Square sync operations. Brand-scoped. - -ALTER TABLE public.square_sync_queue ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "square_sync_queue_platform_admin_all" ON public.square_sync_queue; -DROP POLICY IF EXISTS "square_sync_queue_brand_scoped" ON public.square_sync_queue; - -CREATE POLICY "square_sync_queue_platform_admin_all" ON public.square_sync_queue - FOR ALL TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ); - -CREATE POLICY "square_sync_queue_brand_scoped" ON public.square_sync_queue - FOR ALL TO authenticated - USING (brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - )) - WITH CHECK (brand_id = ( - SELECT brand_id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - LIMIT 1 - )); - -COMMIT; \ No newline at end of file diff --git a/supabase/migrations/.archived/111_route_trace_recent_activity.sql b/supabase/migrations/.archived/111_route_trace_recent_activity.sql deleted file mode 100644 index bc58fed..0000000 --- a/supabase/migrations/.archived/111_route_trace_recent_activity.sql +++ /dev/null @@ -1,96 +0,0 @@ --- 111_route_trace_recent_activity.sql --- RPC for recent lot events (activity feed) + total_lots stat - -BEGIN; - --- ── get_recent_lot_events ────────────────────────────────────────────────────── --- Returns the most recent lot events across all lots for a brand, --- enriched with lot context (lot_number, crop_type) for the dashboard feed. - -DROP TYPE IF EXISTS "public"."recent_lot_event" CASCADE; -CREATE TYPE recent_lot_event AS ( - event_id UUID, - event_type TEXT, - event_time TIMESTAMPTZ, - location TEXT, - bin_id TEXT, - notes TEXT, - created_by_name TEXT, - lot_id UUID, - lot_number TEXT, - crop_type TEXT, - status TEXT -); - -CREATE OR REPLACE FUNCTION get_recent_lot_events(p_brand_id UUID, p_limit INT DEFAULT 10) -RETURNS SETOF recent_lot_event -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - he.id AS event_id, - he.event_type AS event_type, - he.event_time AS event_time, - he.location AS location, - he.bin_id AS bin_id, - he.notes AS notes, - he.created_by_name AS created_by_name, - hl.id AS lot_id, - hl.lot_number AS lot_number, - hl.crop_type AS crop_type, - hl.status AS status - FROM harvest_lot_events he - JOIN harvest_lots hl ON hl.id = he.lot_id - WHERE hl.brand_id = p_brand_id - ORDER BY he.event_time DESC - LIMIT p_limit; -END; -$$; - --- ── Update stats to include total_lots ───────────────────────────────────────── --- get_route_trace_stats already returns a row — add total_lots --- (CREATE OR REPLACE keeps the signature compatible) - -DROP TYPE IF EXISTS "public"."route_trace_stats" CASCADE; -CREATE TYPE route_trace_stats AS ( - active_count INT, - in_transit_count INT, - at_shed_count INT, - total_lots_today INT, - total_harvested_today INT, - total_lots INT -); - -CREATE OR REPLACE FUNCTION get_route_trace_stats(p_brand_id UUID) -RETURNS route_trace_stats -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - result route_trace_stats; -BEGIN - SELECT - COUNT(*) FILTER (WHERE status = 'active') INTO result.active_count, - COUNT(*) FILTER (WHERE status = 'in_transit') INTO result.in_transit_count, - COUNT(*) FILTER (WHERE status = 'at_shed') INTO result.at_shed_count, - COUNT(*) FILTER (WHERE status = 'active' AND DATE(harvest_date) = CURRENT_DATE) - INTO result.total_lots_today, - COALESCE(SUM(quantity_lbs) FILTER (WHERE DATE(harvest_date) = CURRENT_DATE), 0) - INTO result.total_harvested_today, - COUNT(*) INTO result.total_lots - FROM harvest_lots - WHERE brand_id = p_brand_id; - - result.active_count := COALESCE(result.active_count, 0); - result.in_transit_count := COALESCE(result.in_transit_count, 0); - result.at_shed_count := COALESCE(result.at_shed_count, 0); - result.total_lots_today := COALESCE(result.total_lots_today, 0); - result.total_lots := COALESCE(result.total_lots, 0); - - RETURN result; -END; -$$; - -COMMIT; diff --git a/supabase/migrations/.archived/119_regenerate_headgate_token_brand.sql b/supabase/migrations/.archived/119_regenerate_headgate_token_brand.sql deleted file mode 100644 index 915a1c2..0000000 --- a/supabase/migrations/.archived/119_regenerate_headgate_token_brand.sql +++ /dev/null @@ -1,30 +0,0 @@ --- Migration 119: Add brand_id to regenerate_headgate_token for ownership enforcement -CREATE OR REPLACE FUNCTION regenerate_headgate_token(p_headgate_id UUID, p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_new_token UUID; - v_headgate_brand_id UUID; -BEGIN - -- Verify headgate exists and belongs to brand - SELECT brand_id INTO v_headgate_brand_id - FROM water_headgates - WHERE id = p_headgate_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Headgate not found'); - END IF; - - -- Brand scoping: if brand_id is not null, enforce ownership - IF p_brand_id IS NOT NULL AND v_headgate_brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized'); - END IF; - - v_new_token := gen_random_uuid(); - UPDATE water_headgates SET headgate_token = v_new_token WHERE id = p_headgate_id; - - RETURN jsonb_build_object('success', true, 'token', v_new_token); -END; -$$; diff --git a/supabase/migrations/.archived/120_water_alerts.sql b/supabase/migrations/.archived/120_water_alerts.sql deleted file mode 100644 index 5497989..0000000 --- a/supabase/migrations/.archived/120_water_alerts.sql +++ /dev/null @@ -1,269 +0,0 @@ --- Migration 120: Water Log High/Low Alerts --- 1. Add threshold columns to water_headgates --- 2. Add alert config to water_admin_settings --- 3. Create water_alert_log table --- 4. Create trigger_water_alert RPC with Twilio SMS - --- 1. Threshold columns -ALTER TABLE water_headgates ADD COLUMN IF NOT EXISTS high_threshold NUMERIC(12,4); -ALTER TABLE water_headgates ADD COLUMN IF NOT EXISTS low_threshold NUMERIC(12,4); - --- 2. Alert config columns -ALTER TABLE water_admin_settings ADD COLUMN IF NOT EXISTS alert_phone TEXT; -ALTER TABLE water_admin_settings ADD COLUMN IF NOT EXISTS alerts_enabled BOOLEAN NOT NULL DEFAULT false; - --- 3. Alert log table -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, - headgate_id UUID NOT NULL REFERENCES water_headgates(id) ON DELETE CASCADE, - entry_id UUID NOT NULL REFERENCES water_log_entries(id) ON DELETE CASCADE, - alert_type TEXT NOT NULL CHECK (alert_type IN ('high', 'low')), - threshold_value NUMERIC(12,4) NOT NULL, - reading_value NUMERIC(12,4) NOT NULL, - message_sent TEXT, - sent_at TIMESTAMPTZ NOT NULL DEFAULT now(), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- 4. Update save_water_admin_settings to handle new columns -CREATE OR REPLACE FUNCTION save_water_admin_settings( - p_brand_id UUID, - p_enabled BOOLEAN, - p_pin_hash TEXT, - p_session_duration_hours INTEGER, - p_can_edit_entries BOOLEAN, - p_can_delete_entries BOOLEAN, - p_can_export_csv BOOLEAN, - p_alert_phone TEXT DEFAULT NULL, - p_alerts_enabled BOOLEAN DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - INSERT INTO water_admin_settings - (brand_id, enabled, pin_hash, session_duration_hours, can_edit_entries, can_delete_entries, can_export_csv, alert_phone, alerts_enabled) - VALUES - (p_brand_id, p_enabled, p_pin_hash, p_session_duration_hours, p_can_edit_entries, p_can_delete_entries, p_can_export_csv, p_alert_phone, COALESCE(p_alerts_enabled, false)) - ON CONFLICT (brand_id) DO UPDATE SET - enabled = EXCLUDED.enabled, - pin_hash = COALESCE(EXCLUDED.pin_hash, water_admin_settings.pin_hash), - session_duration_hours = EXCLUDED.session_duration_hours, - can_edit_entries = EXCLUDED.can_edit_entries, - can_delete_entries = EXCLUDED.can_delete_entries, - can_export_csv = EXCLUDED.can_export_csv, - alert_phone = COALESCE(EXCLUDED.alert_phone, water_admin_settings.alert_phone), - alerts_enabled = COALESCE(EXCLUDED.alerts_enabled, water_admin_settings.alerts_enabled), - updated_at = now(); - - RETURN jsonb_build_object('success', true); -END; -$$; - --- 5. Update get_water_admin_settings to return new columns -CREATE OR REPLACE FUNCTION get_water_admin_settings(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_build_object( - 'enabled', enabled, - 'session_duration_hours', session_duration_hours, - 'can_edit_entries', can_edit_entries, - 'can_delete_entries', can_delete_entries, - 'can_export_csv', can_export_csv, - 'alert_phone', alert_phone, - 'alerts_enabled', COALESCE(alerts_enabled, false) - ) INTO v_result - FROM water_admin_settings - WHERE brand_id = p_brand_id; - - RETURN v_result; -END; -$$; - --- 6. Update water_headgates admin view/RPC to include thresholds -CREATE OR REPLACE FUNCTION get_water_headgates_admin(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(jsonb_build_object( - 'id', id, - 'name', name, - 'active', active, - 'unit', unit, - 'created_at', created_at, - 'headgate_token', headgate_token, - 'high_threshold', high_threshold, - 'low_threshold', low_threshold, - 'last_used_at', last_used_at - ) ORDER BY created_at DESC) INTO v_result - FROM water_headgates - WHERE brand_id = p_brand_id AND deleted_at IS NULL; - - RETURN jsonb_build_object('headgates', COALESCE(v_result, '[]'::jsonb)); -END; -$$; - --- 7. Trigger alert RPC — checks thresholds and sends Twilio SMS -CREATE OR REPLACE FUNCTION trigger_water_alert( - p_entry_id UUID, - p_alert_type TEXT, - p_threshold_value NUMERIC, - p_reading_value NUMERIC -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_headgate_id UUID; - v_headgate_name TEXT; - v_headgate_high NUMERIC; - v_headgate_low NUMERIC; - v_brand_id UUID; - v_alert_phone TEXT; - v_alerts_enabled BOOLEAN; - v_site_url TEXT; - v_message TEXT; - v_sent BOOLEAN := false; - v_result JSONB; - v_entry_time TEXT; - v_entry_headgate_id UUID; - v_entry_brand_id UUID; - v_entry_measurement NUMERIC; - v_entry_unit TEXT; -BEGIN - -- Look up the entry to get headgate + brand info - SELECT headgate_id, brand_id, measurement, unit, TO_CHAR(logged_at, 'YYYY-MM-DD HH24:MI') INTO v_entry_headgate_id, v_entry_brand_id, v_entry_measurement, v_entry_unit, v_entry_time - FROM water_log_entries - WHERE id = p_entry_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Entry not found'); - END IF; - - -- Look up headgate thresholds - SELECT id, name, high_threshold, low_threshold INTO v_headgate_id, v_headgate_name, v_headgate_high, v_headgate_low - FROM water_headgates - WHERE id = v_entry_headgate_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Headgate not found'); - END IF; - - -- Look up admin settings for this brand - SELECT alert_phone, COALESCE(alerts_enabled, false) INTO v_alert_phone, v_alerts_enabled - FROM water_admin_settings - WHERE brand_id = v_entry_brand_id; - - -- Check if alert should fire - IF NOT v_alerts_enabled OR v_alert_phone IS NULL OR v_alert_phone = '' THEN - RETURN jsonb_build_object('success', true, 'triggered', false, 'reason', 'alerts disabled or no phone'); - END IF; - - -- Check threshold breach - IF p_alert_type = 'high' AND (v_headgate_high IS NULL OR p_reading_value <= v_headgate_high) THEN - RETURN jsonb_build_object('success', true, 'triggered', false, 'reason', 'not above high threshold'); - END IF; - - IF p_alert_type = 'low' AND (v_headgate_low IS NULL OR p_reading_value >= v_headgate_low) THEN - RETURN jsonb_build_object('success', true, 'triggered', false, 'reason', 'not below low threshold'); - END IF; - - -- Build message - v_site_url := COALESCE(NULLIF(COALESCE(current_setting('app.settings.site_url', true), ''), ''), 'https://routecommerce.com'); - - IF p_alert_type = 'high' THEN - v_message := 'Alert: Headgate ''' || v_headgate_name || ''' reading ' || p_reading_value || ' ' || v_entry_unit || ' exceeded High threshold of ' || v_headgate_high || ' at ' || v_entry_time || '. View: ' || v_site_url || '/water/admin'; - ELSE - v_message := 'Alert: Headgate ''' || v_headgate_name || ''' reading ' || p_reading_value || ' ' || v_entry_unit || ' fell below Low threshold of ' || v_headgate_low || ' at ' || v_entry_time || '. View: ' || v_site_url || '/water/admin'; - END IF; - - -- Insert alert log - INSERT INTO water_alert_log (brand_id, headgate_id, entry_id, alert_type, threshold_value, reading_value, message_sent, sent_at) - VALUES (v_entry_brand_id, v_headgate_id, p_entry_id, p_alert_type, p_threshold_value, p_reading_value, v_message, now()) - RETURNING id INTO v_result; - - -- Send SMS via Twilio REST API (ignore failures — alert is logged either way) - PERFORM ( - SELECT net.http_post( - url := 'https://api.twilio.com/2010-04-01/Accounts/' || NULLIF(COALESCE(current_setting('app.settings.twilio_account_sid', true), ''), '') || '/Messages.json', - headers := '{"Authorization": "Basic " || encode((' || Quote_literal(COALESCE(NULLIF(current_setting('app.settings.twilio_account_sid', true), ''), '') || ':' || COALESCE(NULLIF(current_setting('app.settings.twilio_auth_token', true), ''), '')) || ')::bytea, ''base64''), "Content-Type": "application/x-www-form-urlencoded"}', - body := 'From=' || COALESCE(NULLIF(COALESCE(current_setting('app.settings.twilio_phone_number', true), ''), ''), '') || '&To=' || v_alert_phone || '&Body=' || urlencode(v_message) - ) - WHERE NULLIF(COALESCE(current_setting('app.settings.twilio_account_sid', true), ''), '') IS NOT NULL - ); - - RETURN jsonb_build_object('success', true, 'triggered', true, 'message', v_message, 'alert_log_id', v_result); -EXCEPTION - WHEN OTHERS THEN - -- Log but don't fail — alert is recorded - RETURN jsonb_build_object('success', true, 'triggered', true, 'message', v_message, 'sms_error', SQLERRM); -END; -$$; - --- 8. Update update_water_headgate to accept thresholds -CREATE OR REPLACE FUNCTION update_water_headgate( - p_headgate_id UUID, - p_name TEXT, - p_active BOOLEAN, - p_unit TEXT, - p_brand_id UUID, - p_high_threshold NUMERIC DEFAULT NULL, - p_low_threshold NUMERIC DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - UPDATE water_headgates - SET name = p_name, active = p_active, unit = p_unit, - high_threshold = p_high_threshold, low_threshold = p_low_threshold - WHERE id = p_headgate_id AND brand_id = p_brand_id AND deleted_at IS NULL; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Headgate not found'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- 9. Create alert log RPC for fetching -CREATE OR REPLACE FUNCTION get_water_alert_log(p_brand_id UUID, p_limit INTEGER DEFAULT 50) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg(q ORDER BY created_at DESC) - INTO v_result - FROM ( - SELECT - a.id, a.alert_type, a.threshold_value, a.reading_value, - a.message_sent, a.sent_at, a.created_at, - h.name AS headgate_name, - TO_CHAR(a.created_at, 'YYYY-MM-DD HH24:MI') AS formatted_time - FROM water_alert_log a - JOIN water_headgates h ON h.id = a.headgate_id - WHERE a.brand_id = p_brand_id - ORDER BY a.created_at DESC - LIMIT p_limit - ) q; - - RETURN jsonb_build_object('alerts', COALESCE(v_result, '[]'::jsonb)); -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/126_founder_pain_log.sql b/supabase/migrations/.archived/126_founder_pain_log.sql deleted file mode 100644 index 993c031..0000000 --- a/supabase/migrations/.archived/126_founder_pain_log.sql +++ /dev/null @@ -1,39 +0,0 @@ --- Migration 126: founder_pain_log table and platform view - -CREATE TABLE founder_pain_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - brand_id UUID REFERENCES brands(id) ON DELETE SET NULL, - severity TEXT NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')), - category TEXT NOT NULL, -- e.g. 'deployment', 'payment', 'data', 'operations' - title TEXT NOT NULL, - description TEXT, - status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved')), - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - resolved_at TIMESTAMPTZ, - resolved_by UUID REFERENCES admin_users(id) ON DELETE SET NULL -); - -CREATE VIEW founder_pain_log_platform AS -SELECT - fpl.id, - fpl.brand_id, - fpl.severity, - fpl.category, - fpl.title, - fpl.description, - fpl.status, - fpl.created_at, - fpl.resolved_at, - fpl.resolved_by, - b.name AS brand_name, - b.slug AS brand_slug -FROM founder_pain_log fpl -LEFT JOIN brands b ON b.id = fpl.brand_id -ORDER BY - CASE fpl.severity - WHEN 'critical' THEN 1 - WHEN 'high' THEN 2 - WHEN 'medium' THEN 3 - WHEN 'low' THEN 4 - END, - fpl.created_at DESC; \ No newline at end of file diff --git a/supabase/migrations/.archived/127_platform_command_center_rpc.sql b/supabase/migrations/.archived/127_platform_command_center_rpc.sql deleted file mode 100644 index 8e8159f..0000000 --- a/supabase/migrations/.archived/127_platform_command_center_rpc.sql +++ /dev/null @@ -1,155 +0,0 @@ --- Migration 127: Platform command center RPCs (final applied version) - --- Platform-wide metrics -CREATE OR REPLACE FUNCTION get_platform_command_center_metrics() -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - result JSONB; -BEGIN - result := jsonb_build_object( - 'active_brands', ( - SELECT COUNT(*) FROM brands WHERE slug IN ('tuxedo', 'indian-river-direct') - ), - 'orders_today', ( - SELECT COUNT(*) FROM orders - WHERE DATE(created_at) = CURRENT_DATE - ), - 'revenue_today', ( - SELECT COALESCE(SUM(subtotal), 0) - FROM orders - WHERE DATE(created_at) = CURRENT_DATE - AND status NOT IN ('cancelled', 'refunded') - ), - 'active_routes', ( - SELECT COUNT(DISTINCT s.id) - FROM stops s - WHERE s.date::DATE >= CURRENT_DATE - AND s.status = 'active' - AND s.date ~ '^\d{4}-\d{2}-\d{2}$' - ), - 'failed_orders_today', ( - SELECT COUNT(*) - FROM orders - WHERE DATE(created_at) = CURRENT_DATE - AND status IN ('payment_failed', 'failed') - ), - 'pending_orders_today', ( - SELECT COUNT(*) - FROM orders - WHERE DATE(created_at) = CURRENT_DATE - AND status = 'pending' - ) - ); - - RETURN result; -END; -$$; - --- Recent activity feed (last 50 operational events across all brands) -CREATE OR REPLACE FUNCTION get_platform_activity_feed() -RETURNS TABLE ( - id UUID, - event_type TEXT, - entity_type TEXT, - entity_id UUID, - payload JSONB, - actor_type TEXT, - source TEXT, - created_at TIMESTAMPTZ, - brand_id UUID, - brand_name TEXT -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - oe.id, - oe.event_type, - oe.entity_type, - oe.entity_id, - oe.payload, - oe.actor_type::TEXT, - oe.source::TEXT, - oe.created_at, - (oe.payload->>'brand_id')::UUID AS brand_id, - b.name AS brand_name - FROM operational_events oe - LEFT JOIN brands b ON b.id = (oe.payload->>'brand_id')::UUID - ORDER BY oe.created_at DESC - LIMIT 50; -END; -$$; - --- Per-brand health snapshot -DROP FUNCTION IF EXISTS get_brand_health_snapshot(); -CREATE OR REPLACE FUNCTION get_brand_health_snapshot() -RETURNS TABLE ( - brand_id UUID, - brand_name TEXT, - brand_slug TEXT, - plan_tier TEXT, - orders_today INT, - revenue_today NUMERIC, - active_stops INT, - failed_orders INT, - pending_orders INT, - last_activity TIMESTAMPTZ, - open_pain_items INT, - health_status TEXT -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - WITH bm AS ( - SELECT - b.id AS bid, - b.name AS bname, - b.slug AS bslug, - COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE) AS ords_today, - SUM(o.subtotal) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status NOT IN ('cancelled', 'refunded')) AS rev_today, - COUNT(DISTINCT s.id) FILTER ( - WHERE (s.date ~ '^\d{4}-\d{2}-\d{2}$') AND (s.date::DATE >= CURRENT_DATE) AND s.status = 'active' - ) AS act_stops, - COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status IN ('payment_failed', 'failed')) AS fail_ords, - COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status = 'pending') AS pend_ords, - MAX(oe.created_at) AS last_act - FROM brands b - LEFT JOIN orders o ON o.brand_id = b.id - LEFT JOIN stops s ON s.brand_id = b.id - LEFT JOIN operational_events oe ON oe.payload->>'brand_id' = b.id::TEXT - WHERE b.slug IN ('tuxedo', 'indian-river-direct') - GROUP BY b.id, b.name, b.slug - ), - pc AS ( - SELECT f.brand_id AS pc_bid, COUNT(*) AS open_cnt - FROM founder_pain_log f - WHERE f.status = 'open' - GROUP BY f.brand_id - ) - SELECT - bm.bid, - bm.bname, - bm.bslug, - NULL::TEXT, - COALESCE(bm.ords_today, 0)::INT, - COALESCE(bm.rev_today, 0)::NUMERIC, - COALESCE(bm.act_stops, 0)::INT, - COALESCE(bm.fail_ords, 0)::INT, - COALESCE(bm.pend_ords, 0)::INT, - bm.last_act, - COALESCE(pc.open_cnt, 0)::INT, - CASE - WHEN COALESCE(bm.fail_ords, 0) > 0 THEN 'critical' - WHEN COALESCE(bm.pend_ords, 0) > 5 THEN 'warning' - WHEN COALESCE(pc.open_cnt, 0) > 0 THEN 'warning' - WHEN bm.last_act IS NULL OR bm.last_act < (NOW() - INTERVAL '24 hours') THEN 'warning' - ELSE 'healthy' - END - FROM bm - LEFT JOIN pc ON pc.pc_bid = bm.bid; -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/128_fix_get_water_headgates_admin_last_used_at.sql b/supabase/migrations/.archived/128_fix_get_water_headgates_admin_last_used_at.sql deleted file mode 100644 index 8608a30..0000000 --- a/supabase/migrations/.archived/128_fix_get_water_headgates_admin_last_used_at.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Fix get_water_headgates_admin: removed last_used_at column that doesn't exist in water_headgates table -CREATE OR REPLACE FUNCTION public.get_water_headgates_admin(p_brand_id uuid) - RETURNS jsonb - LANGUAGE plpgsql - SECURITY DEFINER -AS $function$ DECLARE v_result JSONB; BEGIN -SELECT jsonb_agg(jsonb_build_object('id', id, 'name', name, 'active', active, 'unit', unit, 'created_at', created_at, 'headgate_token', headgate_token, 'high_threshold', high_threshold, 'low_threshold', low_threshold) ORDER BY created_at DESC) INTO v_result -FROM water_headgates -WHERE brand_id = p_brand_id AND deleted_at IS NULL; -RETURN jsonb_build_object('headgates', COALESCE(v_result, '[]'::jsonb)); -END; $function$ \ No newline at end of file diff --git a/supabase/migrations/.archived/129_worker_time_tracking.sql b/supabase/migrations/.archived/129_worker_time_tracking.sql deleted file mode 100644 index be660b0..0000000 --- a/supabase/migrations/.archived/129_worker_time_tracking.sql +++ /dev/null @@ -1,569 +0,0 @@ --- Worker Time Tracking --- Tables: time_tracking_workers, time_tracking_tasks, worker_time_logs - --- ── Workers ──────────────────────────────────────────────────────────────────── - -CREATE TABLE IF NOT EXISTS time_tracking_workers ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - brand_id UUID REFERENCES brands(id) ON DELETE CASCADE NOT NULL, - name TEXT NOT NULL, - pin_hash TEXT NOT NULL, -- bcrypt hash of 4-6 digit PIN - role TEXT NOT NULL DEFAULT 'worker', -- 'worker' | 'time_admin' - lang TEXT NOT NULL DEFAULT 'en', -- 'en' | 'es' - active BOOLEAN NOT NULL DEFAULT true, - last_used_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -ALTER TABLE time_tracking_workers ENABLE ROW LEVEL SECURITY; - --- Workers: anyone can verify PIN via RPC; updates only via SECURITY DEFINER RPCs -CREATE POLICY "Workers readable by authenticated" ON time_tracking_workers - FOR SELECT TO authenticated USING (true); - -CREATE POLICY "Workers insertable by postgres" ON time_tracking_workers - FOR INSERT TO postgres WITH CHECK (true); - -CREATE POLICY "Workers updatable by postgres" ON time_tracking_workers - FOR UPDATE TO postgres USING (true); - --- ── Tasks ─────────────────────────────────────────────────────────────────────── - -CREATE TABLE IF NOT EXISTS time_tracking_tasks ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - brand_id UUID REFERENCES brands(id) ON DELETE CASCADE NOT NULL, - name TEXT NOT NULL, - name_es TEXT, -- Spanish name - unit TEXT NOT NULL DEFAULT 'hours', -- 'hours' | 'pieces' | 'units' - active BOOLEAN NOT NULL DEFAULT true, - sort_order INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -ALTER TABLE time_tracking_tasks ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Tasks readable by all" ON time_tracking_tasks - FOR SELECT TO anon USING (true); - -CREATE POLICY "Tasks writable by postgres" ON time_tracking_tasks - FOR ALL TO postgres USING (true); - --- ── Time Logs ────────────────────────────────────────────────────────────────── - -CREATE TABLE IF NOT EXISTS worker_time_logs ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - brand_id UUID REFERENCES brands(id) ON DELETE CASCADE NOT NULL, - worker_id UUID REFERENCES time_tracking_workers(id) ON DELETE CASCADE NOT NULL, - task_id UUID REFERENCES time_tracking_tasks(id) ON DELETE SET NULL, - task_name TEXT NOT NULL, -- snapshot at time of clock-out - clock_in TIMESTAMPTZ NOT NULL, - clock_out TIMESTAMPTZ, -- null = currently clocked in - lunch_break_minutes INT NOT NULL DEFAULT 0, - notes TEXT, - submitted_via TEXT NOT NULL DEFAULT 'field', -- 'field' | 'admin' - created_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, - updated_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -ALTER TABLE worker_time_logs ENABLE ROW LEVEL SECURITY; - --- Time logs: readable by authenticated (brand-scoped), writable by postgres -CREATE POLICY "Time logs readable by authenticated" ON worker_time_logs - FOR SELECT TO authenticated USING (true); - -CREATE POLICY "Time logs insertable by postgres" ON worker_time_logs - FOR INSERT TO postgres WITH CHECK (true); - -CREATE POLICY "Time logs updatable by postgres" ON worker_time_logs - FOR UPDATE TO postgres USING (true); - --- ── Indexes ───────────────────────────────────────────────────────────────────── - -CREATE INDEX IF NOT EXISTS idx_worker_time_logs_brand_id ON worker_time_logs(brand_id); -CREATE INDEX IF NOT EXISTS idx_worker_time_logs_worker_id ON worker_time_logs(worker_id); -CREATE INDEX IF NOT EXISTS idx_worker_time_logs_clock_in ON worker_time_logs(clock_in); -CREATE INDEX IF NOT EXISTS idx_worker_time_logs_clock_out ON worker_time_logs(clock_out) WHERE clock_out IS NULL; -CREATE INDEX IF NOT EXISTS idx_time_tracking_workers_brand ON time_tracking_workers(brand_id); -CREATE INDEX IF NOT EXISTS idx_time_tracking_tasks_brand ON time_tracking_tasks(brand_id); - --- ── RPCs ──────────────────────────────────────────────────────────────────────── - --- Verify PIN and return worker info + create session -CREATE OR REPLACE FUNCTION verify_time_tracking_pin( - p_brand_id UUID, - p_pin TEXT -) RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - w time_tracking_workers; - session_id UUID; - diff_secs INT; -BEGIN - SELECT * INTO w - FROM time_tracking_workers - WHERE brand_id = p_brand_id AND active = true AND pin_hash = p_pin; - - IF w IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN'); - END IF; - - -- Update last used - UPDATE time_tracking_workers SET last_used_at = now() WHERE id = w.id; - - -- Create session record - session_id = gen_random_uuid(); - -- Sessions expire in 12 hours (no session table needed — validated by timestamp comparison) - - RETURN jsonb_build_object( - 'success', true, - 'worker_id', w.id, - 'name', w.name, - 'role', w.role, - 'lang', w.lang, - 'session_id', session_id, - 'brand_id', p_brand_id, - 'expires_at', now() + INTERVAL '12 hours' - ); -END; -$$; - --- Clock in — create a new time log entry -CREATE OR REPLACE FUNCTION clock_in_worker( - p_brand_id UUID, - p_worker_id UUID, - p_task_id UUID DEFAULT NULL, - p_task_name TEXT DEFAULT 'General Labor' -) RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - existing_open time_tracking_logs%ROWTYPE; - log_id UUID; -BEGIN - -- Check for open entry (no clock_out) - SELECT * INTO existing_open - FROM worker_time_logs - WHERE worker_id = p_worker_id AND clock_out IS NULL - LIMIT 1; - - IF found THEN - RETURN jsonb_build_object('success', false, 'error', 'Already clocked in'); - END IF; - - INSERT INTO worker_time_logs (brand_id, worker_id, task_id, task_name, clock_in) - VALUES (p_brand_id, p_worker_id, p_task_id, p_task_name, now()) - RETURNING id INTO log_id; - - RETURN jsonb_build_object('success', true, 'log_id', log_id, 'clock_in', now()); -END; -$$; - --- Clock out — close the open entry for this worker -CREATE OR REPLACE FUNCTION clock_out_worker( - p_worker_id UUID, - p_lunch_minutes INT DEFAULT 0, - p_notes TEXT DEFAULT NULL -) RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - open_log worker_time_logs%ROWTYPE; - diff_mins INT; -BEGIN - SELECT * INTO open_log - FROM worker_time_logs - WHERE worker_id = p_worker_id AND clock_out IS NULL - ORDER BY clock_in DESC LIMIT 1; - - IF open_log IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'No open clock-in found'); - END IF; - - diff_mins = EXTRACT(EPOCH FROM (now() - open_log.clock_in))::INT / 60 - p_lunch_minutes; - - UPDATE worker_time_logs - SET clock_out = now(), - lunch_break_minutes = p_lunch_minutes, - notes = COALESCE(NULLIF(TRIM(p_notes), ''), NULL), - updated_at = now() - WHERE id = open_log.id; - - RETURN jsonb_build_object( - 'success', true, - 'log_id', open_log.id, - 'clock_out', now(), - 'total_minutes', GREATEST(0, diff_mins) - ); -END; -$$; - --- Get open clock-in for a worker (for resuming) -CREATE OR REPLACE FUNCTION get_open_clock_in(p_worker_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - open_log worker_time_logs%ROWTYPE; -BEGIN - SELECT * INTO open_log - FROM worker_time_logs - WHERE worker_id = p_worker_id AND clock_out IS NULL - ORDER BY clock_in DESC LIMIT 1; - - IF open_log IS NULL THEN - RETURN jsonb_build_object('success', true, 'open', false); - END IF; - - RETURN jsonb_build_object( - 'success', true, - 'open', true, - 'log_id', open_log.id, - 'task_name', open_log.task_name, - 'clock_in', open_log.clock_in, - 'elapsed_minutes', EXTRACT(EPOCH FROM (now() - open_log.clock_in))::INT / 60 - ); -END; -$$; - --- Get workers for a brand (no pin_hash exposed) -CREATE OR REPLACE FUNCTION get_time_tracking_workers(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('workers', COALESCE( - jsonb_agg( - jsonb_build_object( - 'id', id, 'name', name, 'role', role, 'lang', lang, - 'active', active, 'last_used_at', last_used_at, 'created_at', created_at - ) ORDER BY name - ), '[]'::jsonb - )) - FROM time_tracking_workers - WHERE brand_id = p_brand_id - ); -END; -$$; - --- Create worker — auto-generates 4-digit PIN -CREATE OR REPLACE FUNCTION create_time_worker( - p_brand_id UUID, - p_name TEXT, - p_role TEXT DEFAULT 'worker', - p_lang TEXT DEFAULT 'en' -) RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - new_pin TEXT; - new_hash TEXT; - new_id UUID; -BEGIN - -- Generate random 4-digit PIN - new_pin = lpad((FLOOR(RANDOM() * 9000 + 1000))::INT::TEXT, 4, '0'); - -- Simple hash (for demo; use pgcrypto in production) - new_hash = 'pin:' || new_pin; - - INSERT INTO time_tracking_workers (brand_id, name, pin_hash, role, lang) - VALUES (p_brand_id, p_name, new_hash, p_role, p_lang) - RETURNING id INTO new_id; - - RETURN jsonb_build_object( - 'success', true, - 'worker', jsonb_build_object('id', new_id, 'name', p_name, 'role', p_role, 'lang', p_lang), - 'pin', new_pin - ); -END; -$$; - --- Reset worker PIN — returns new PIN (shown once) -CREATE OR REPLACE FUNCTION reset_time_worker_pin(p_worker_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - new_pin TEXT; - new_hash TEXT; -BEGIN - new_pin = lpad((FLOOR(RANDOM() * 9000 + 1000))::INT::TEXT, 4, '0'); - new_hash = 'pin:' || new_pin; - UPDATE time_tracking_workers SET pin_hash = new_hash WHERE id = p_worker_id; - RETURN jsonb_build_object('success', true, 'pin', new_pin); -END; -$$; - --- Update worker -CREATE OR REPLACE FUNCTION update_time_worker( - p_worker_id UUID, - p_name TEXT, - p_role TEXT, - p_lang TEXT, - p_active BOOLEAN -) RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - UPDATE time_tracking_workers - SET name = p_name, role = p_role, lang = p_lang, active = p_active - WHERE id = p_worker_id; - RETURN jsonb_build_object('success', true); -END; -$$; - --- Delete worker -CREATE OR REPLACE FUNCTION delete_time_worker(p_worker_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - DELETE FROM time_tracking_workers WHERE id = p_worker_id; - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── Tasks RPCs ──────────────────────────────────────────────────────────────── - -CREATE OR REPLACE FUNCTION get_time_tracking_tasks(p_brand_id UUID, p_active_only BOOLEAN DEFAULT false) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('tasks', COALESCE( - jsonb_agg( - jsonb_build_object( - 'id', id, 'name', name, 'name_es', name_es, 'unit', unit, - 'active', active, 'sort_order', sort_order - ) ORDER BY sort_order, name - ), '[]'::jsonb - )) - FROM time_tracking_tasks - WHERE brand_id = p_brand_id - AND (NOT p_active_only OR active = true) - ); -END; -$$; - -CREATE OR REPLACE FUNCTION create_time_task( - p_brand_id UUID, - p_name TEXT, - p_name_es TEXT DEFAULT NULL, - p_unit TEXT DEFAULT 'hours', - p_sort_order INT DEFAULT 0 -) RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - new_id UUID; -BEGIN - INSERT INTO time_tracking_tasks (brand_id, name, name_es, unit, sort_order) - VALUES (p_brand_id, p_name, p_name_es, p_unit, p_sort_order) - RETURNING id INTO new_id; - RETURN jsonb_build_object('success', true, 'id', new_id); -END; -$$; - -CREATE OR REPLACE FUNCTION update_time_task( - p_task_id UUID, - p_name TEXT, - p_name_es TEXT, - p_unit TEXT, - p_active BOOLEAN, - p_sort_order INT -) RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - UPDATE time_tracking_tasks - SET name = p_name, name_es = p_name_es, unit = p_unit, - active = p_active, sort_order = p_sort_order - WHERE id = p_task_id; - RETURN jsonb_build_object('success', true); -END; -$$; - -CREATE OR REPLACE FUNCTION delete_time_task(p_task_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - DELETE FROM time_tracking_tasks WHERE id = p_task_id; - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── Time Log RPCs ───────────────────────────────────────────────────────────── - -CREATE OR REPLACE FUNCTION get_worker_time_logs( - p_brand_id UUID, - p_worker_id UUID DEFAULT NULL, - p_task_id UUID DEFAULT NULL, - p_start TIMESTAMPTZ DEFAULT NULL, - p_end TIMESTAMPTZ DEFAULT NULL, - p_limit INT DEFAULT 100 -) RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN ( - SELECT jsonb_build_object('logs', COALESCE( - jsonb_agg( - jsonb_build_object( - 'id', l.id, - 'worker_id', l.worker_id, - 'worker_name', w.name, - 'task_id', l.task_id, - 'task_name', l.task_name, - 'clock_in', l.clock_in, - 'clock_out', l.clock_out, - 'lunch_break_minutes', l.lunch_break_minutes, - 'notes', l.notes, - 'submitted_via', l.submitted_via, - 'total_minutes', - CASE - WHEN l.clock_out IS NOT NULL - THEN LEAST(GREATEST(0, EXTRACT(EPOCH FROM (l.clock_out - l.clock_in))::INT / 60 - l.lunch_break_minutes), 1440) - ELSE LEAST(GREATEST(0, EXTRACT(EPOCH FROM (now() - l.clock_in))::INT / 60), 1440) - END, - 'created_at', l.created_at - ) ORDER BY l.clock_in DESC - ), '[]'::jsonb - )) - FROM worker_time_logs l - JOIN time_tracking_workers w ON w.id = l.worker_id - WHERE l.brand_id = p_brand_id - AND (p_worker_id IS NULL OR l.worker_id = p_worker_id) - AND (p_task_id IS NULL OR l.task_id = p_task_id) - AND (p_start IS NULL OR l.clock_in >= p_start) - AND (p_end IS NULL OR l.clock_in <= p_end) - LIMIT p_limit - ); -END; -$$; - -CREATE OR REPLACE FUNCTION update_worker_time_log( - p_log_id UUID, - p_task_name TEXT, - p_clock_in TIMESTAMPTZ, - p_clock_out TIMESTAMPTZ, - p_lunch_minutes INT, - p_notes TEXT -) RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - UPDATE worker_time_logs - SET task_name = p_task_name, - clock_in = p_clock_in, - clock_out = p_clock_out, - lunch_break_minutes = p_lunch_minutes, - notes = NULLIF(TRIM(p_notes), ''), - updated_at = now() - WHERE id = p_log_id; - RETURN jsonb_build_object('success', true); -END; -$$; - -CREATE OR REPLACE FUNCTION delete_worker_time_log(p_log_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - DELETE FROM worker_time_logs WHERE id = p_log_id; - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── Summary RPC (for admin dashboard) ───────────────────────────────────────── - -CREATE OR REPLACE FUNCTION get_time_tracking_summary( - p_brand_id UUID, - p_start TIMESTAMPTZ, - p_end TIMESTAMPTZ -) RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN jsonb_build_object( - 'by_worker', ( - SELECT COALESCE(jsonb_agg(r ORDER BY total_hours DESC), '[]'::jsonb) - FROM ( - SELECT w.id, w.name, - COUNT(l.id) AS entry_count, - SUM( - LEAST(GREATEST(0, EXTRACT(EPOCH FROM (COALESCE(l.clock_out, now()) - l.clock_in))::INT / 60 - l.lunch_break_minutes), 1440) - ) / 60.0 AS total_hours - FROM time_tracking_workers w - LEFT JOIN worker_time_logs l ON l.worker_id = w.id - AND l.clock_in >= p_start AND l.clock_in <= p_end - WHERE w.brand_id = p_brand_id - GROUP BY w.id, w.name - ) r - ), - 'by_task', ( - SELECT COALESCE(jsonb_agg(r ORDER BY total_hours DESC), '[]'::jsonb) - FROM ( - SELECT t.id, t.name, t.name_es, - COUNT(l.id) AS entry_count, - SUM( - LEAST(GREATEST(0, EXTRACT(EPOCH FROM (COALESCE(l.clock_out, now()) - l.clock_in))::INT / 60 - l.lunch_break_minutes), 1440) - ) / 60.0 AS total_hours - FROM time_tracking_tasks t - LEFT JOIN worker_time_logs l ON l.task_id = t.id - AND l.clock_in >= p_start AND l.clock_in <= p_end - WHERE t.brand_id = p_brand_id - GROUP BY t.id, t.name, t.name_es - ) r - ), - 'totals', ( - SELECT jsonb_build_object( - 'entry_count', COUNT(*), - 'total_hours', SUM( - LEAST(GREATEST(0, EXTRACT(EPOCH FROM (COALESCE(clock_out, now()) - clock_in))::INT / 60 - lunch_break_minutes), 1440) - ) / 60.0, - 'open_count', COUNT(*) FILTER (WHERE clock_out IS NULL) - ) - FROM worker_time_logs - WHERE brand_id = p_brand_id AND clock_in >= p_start AND clock_in <= p_end - ) - ); -END; -$$; diff --git a/supabase/migrations/.archived/130_time_tracking_pay_period_overtime.sql b/supabase/migrations/.archived/130_time_tracking_pay_period_overtime.sql deleted file mode 100644 index 3fe8eaf..0000000 --- a/supabase/migrations/.archived/130_time_tracking_pay_period_overtime.sql +++ /dev/null @@ -1,186 +0,0 @@ --- Time Tracking Phase 2: Pay Period + Overtime Settings - --- ── Settings Table ─────────────────────────────────────────────────────────── - -CREATE TABLE IF NOT EXISTS time_tracking_settings ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - brand_id UUID REFERENCES brands(id) ON DELETE CASCADE UNIQUE NOT NULL, - pay_period_start_day INT NOT NULL DEFAULT 0, -- 0=Sun, 1=Mon, ... 6=Sat - pay_period_length_days INT NOT NULL DEFAULT 7, - daily_overtime_threshold NUMERIC(5,2) NOT NULL DEFAULT 8.0, -- hours - weekly_overtime_threshold NUMERIC(5,2) NOT NULL DEFAULT 40.0, -- hours - overtime_multiplier NUMERIC(3,2) NOT NULL DEFAULT 1.50, - overtime_notifications BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -ALTER TABLE time_tracking_settings ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Time tracking settings readable by authenticated" ON time_tracking_settings - FOR SELECT TO authenticated USING (true); - -CREATE POLICY "Time tracking settings writable by postgres" ON time_tracking_settings - FOR ALL TO postgres USING (true); - --- ── RPCs ─────────────────────────────────────────────────────────────────────── - --- Get or init time tracking settings for a brand -CREATE OR REPLACE FUNCTION get_time_tracking_settings(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - s time_tracking_settings%ROWTYPE; -BEGIN - SELECT * INTO s FROM time_tracking_settings WHERE brand_id = p_brand_id; - IF s IS NULL THEN - INSERT INTO time_tracking_settings (brand_id) VALUES (p_brand_id) - RETURNING * INTO s; - END IF; - RETURN jsonb_build_object( - 'id', s.id, - 'brand_id', s.brand_id, - 'pay_period_start_day', s.pay_period_start_day, - 'pay_period_length_days', s.pay_period_length_days, - 'daily_overtime_threshold', s.daily_overtime_threshold, - 'weekly_overtime_threshold', s.weekly_overtime_threshold, - 'overtime_multiplier', s.overtime_multiplier, - 'overtime_notifications', s.overtime_notifications - ); -END; -$$; - --- Update time tracking settings -CREATE OR REPLACE FUNCTION update_time_tracking_settings( - p_brand_id UUID, - p_pay_period_start_day INT, - p_pay_period_length_days INT, - p_daily_threshold NUMERIC(5,2), - p_weekly_threshold NUMERIC(5,2), - p_overtime_multiplier NUMERIC(3,2), - p_overtime_notifications BOOLEAN -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - UPDATE time_tracking_settings - SET pay_period_start_day = p_pay_period_start_day, - pay_period_length_days = p_pay_period_length_days, - daily_overtime_threshold = p_daily_threshold, - weekly_overtime_threshold = p_weekly_threshold, - overtime_multiplier = p_overtime_multiplier, - overtime_notifications = p_overtime_notifications, - updated_at = now() - WHERE brand_id = p_brand_id; - RETURN jsonb_build_object('success', true); -END; -$$; - --- Get worker's hours for current pay period with overtime flags --- Returns: total_minutes, total_hours, daily_hours (today), weekly_hours (current week), --- daily_overtime (bool), weekly_overtime (bool) -CREATE OR REPLACE FUNCTION get_worker_pay_period_hours( - p_brand_id UUID, - p_worker_id UUID, - p_pay_period_start_day INT DEFAULT 0, - p_pay_period_length INT DEFAULT 7 -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - now_ts TIMESTAMPTZ := now(); - week_start TIMESTAMPTZ; - period_start TIMESTAMPTZ; - period_end TIMESTAMPTZ; - today_start TIMESTAMPTZ; - today_end TIMESTAMPTZ; - total_mins INT := 0; - today_mins INT := 0; - week_mins INT := 0; - settings_rec time_tracking_settings%ROWTYPE; - daily_flag BOOLEAN := false; - weekly_flag BOOLEAN := false; -BEGIN - -- Get settings (use defaults if not found) - SELECT * INTO settings_rec FROM time_tracking_settings WHERE brand_id = p_brand_id; - IF settings_rec IS NOT NULL THEN - p_pay_period_start_day := settings_rec.pay_period_start_day; - p_pay_period_length := settings_rec.pay_period_length_days; - END IF; - - -- Compute week start (Sunday or Monday based on p_pay_period_start_day) - week_start := date_trunc('week', now_ts)::TIMESTAMPTZ; - -- Adjust if pay period starts on Monday (1) - IF p_pay_period_start_day = 1 THEN - week_start := (date_trunc('week', now_ts) + INTERVAL '1 day')::TIMESTAMPTZ; - END IF; - - -- Compute pay period boundaries - -- Period is p_pay_period_length days before now, aligned to week start + n * period_length - period_end := now_ts; - period_start := (now_ts - (p_pay_period_length || ' days')::INTERVAL)::TIMESTAMPTZ; - - -- Today boundaries - today_start := (date_trunc('day', now_ts))::TIMESTAMPTZ; - today_end := (date_trunc('day', now_ts) + INTERVAL '1 day')::TIMESTAMPTZ; - - -- Total hours in pay period for this worker - SELECT COALESCE(SUM( - LEAST(GREATEST(0, EXTRACT(EPOCH FROM (COALESCE(l.clock_out, now_ts) - l.clock_in))::INT / 60 - l.lunch_break_minutes), 1440) - ), 0)::INT - INTO total_mins - FROM worker_time_logs l - WHERE l.worker_id = p_worker_id - AND l.clock_in >= period_start AND l.clock_in <= period_end; - - -- Today's hours - SELECT COALESCE(SUM( - LEAST(GREATEST(0, EXTRACT(EPOCH FROM (COALESCE(l.clock_out, now_ts) - l.clock_in))::INT / 60 - l.lunch_break_minutes), 1440) - ), 0)::INT - INTO today_mins - FROM worker_time_logs l - WHERE l.worker_id = p_worker_id - AND l.clock_in >= today_start AND l.clock_in < today_end; - - -- Current week hours - SELECT COALESCE(SUM( - LEAST(GREATEST(0, EXTRACT(EPOCH FROM (COALESCE(l.clock_out, now_ts) - l.clock_in))::INT / 60 - l.lunch_break_minutes), 1440) - ), 0)::INT - INTO week_mins - FROM worker_time_logs l - WHERE l.worker_id = p_worker_id - AND l.clock_in >= week_start; - - -- Check overtime thresholds (using settings or defaults) - IF settings_rec IS NOT NULL THEN - daily_flag := (today_mins / 60.0) >= settings_rec.daily_overtime_threshold; - weekly_flag := (week_mins / 60.0) >= settings_rec.weekly_overtime_threshold; - ELSE - daily_flag := (today_mins / 60.0) >= 8.0; - weekly_flag := (week_mins / 60.0) >= 40.0; - END IF; - - RETURN jsonb_build_object( - 'success', true, - 'total_minutes', total_mins, - 'total_hours', (total_mins / 60.0)::NUMERIC(6,2), - 'daily_minutes', today_mins, - 'daily_hours', (today_mins / 60.0)::NUMERIC(6,2), - 'weekly_minutes', week_mins, - 'weekly_hours', (week_mins / 60.0)::NUMERIC(6,2), - 'daily_overtime', daily_flag, - 'weekly_overtime', weekly_flag, - 'period_start', period_start, - 'period_end', period_end - ); -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/133_time_tracking_notifications.sql b/supabase/migrations/.archived/133_time_tracking_notifications.sql deleted file mode 100644 index e2f419b..0000000 --- a/supabase/migrations/.archived/133_time_tracking_notifications.sql +++ /dev/null @@ -1,349 +0,0 @@ --- Migration 133: Time Tracking Overtime Notification System --- Adds notification recipient config, alert thresholds, and audit log - -BEGIN; - --- ── Expand time_tracking_settings ────────────────────────────────────────── - -ALTER TABLE time_tracking_settings - ADD COLUMN IF NOT EXISTS notification_emails TEXT[] NOT NULL DEFAULT '{}', - ADD COLUMN IF NOT EXISTS notification_sms_numbers TEXT[] NOT NULL DEFAULT '{}', - ADD COLUMN IF NOT EXISTS enable_daily_alerts BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS enable_weekly_alerts BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS daily_alert_threshold NUMERIC(5,2) NOT NULL DEFAULT 0.80, -- 80% - ADD COLUMN IF NOT EXISTS weekly_alert_threshold NUMERIC(5,2) NOT NULL DEFAULT 0.80, -- 80% - ADD COLUMN IF NOT EXISTS send_end_of_period_summary BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS brand_name TEXT NOT NULL DEFAULT 'Farm'; - -COMMENT ON COLUMN time_tracking_settings.notification_emails IS 'Email addresses to send overtime alerts to'; -COMMENT ON COLUMN time_tracking_settings.notification_sms_numbers IS 'Phone numbers to send SMS alerts to (E.164 format)'; -COMMENT ON COLUMN time_tracking_settings.daily_alert_threshold IS 'Send alert when daily hours reach this fraction of threshold (0.80 = 80%)'; -COMMENT ON COLUMN time_tracking_settings.weekly_alert_threshold IS 'Send alert when weekly hours reach this fraction of threshold (0.80 = 80%)'; -COMMENT ON COLUMN time_tracking_settings.brand_name IS 'Display name used in notification emails'; - --- ── Notification audit log ───────────────────────────────────────────────── - -CREATE TABLE IF NOT EXISTS time_tracking_notification_log ( - id UUID NOT NULL DEFAULT gen_random_uuid(), - brand_id UUID NOT NULL, - worker_id UUID, - worker_name TEXT, - trigger_type TEXT NOT NULL CHECK (trigger_type IN ( - 'daily_approaching','daily_reached', - 'weekly_approaching','weekly_reached', - 'end_of_period_summary' - )), - threshold_hours NUMERIC(8,2), - actual_hours NUMERIC(8,2), - emails_sent TEXT[] NOT NULL DEFAULT '{}', - sms_numbers_sent TEXT[] NOT NULL DEFAULT '{}', - email_sent BOOLEAN NOT NULL DEFAULT false, - sms_sent BOOLEAN NOT NULL DEFAULT false, - error_message TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -ALTER TABLE time_tracking_notification_log ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Notification log readable by authenticated" - ON time_tracking_notification_log FOR SELECT - USING (true); - -CREATE POLICY "Notification log writable by postgres" - ON time_tracking_notification_log FOR ALL - USING (true); - --- ── RPC: get_notification_log ────────────────────────────────────────────── - -CREATE OR REPLACE FUNCTION get_time_tracking_notification_log( - p_brand_id UUID, - p_limit INTEGER DEFAULT 100 -) -RETURNS TABLE ( - id UUID, - worker_id UUID, - worker_name TEXT, - trigger_type TEXT, - threshold_hours NUMERIC(8,2), - actual_hours NUMERIC(8,2), - emails_sent TEXT[], - sms_numbers_sent TEXT[], - email_sent BOOLEAN, - sms_sent BOOLEAN, - error_message TEXT, - created_at TIMESTAMPTZ -) -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN QUERY - SELECT - nl.id, nl.worker_id, nl.worker_name, nl.trigger_type, - nl.threshold_hours, nl.actual_hours, - nl.emails_sent, nl.sms_numbers_sent, - nl.email_sent, nl.sms_sent, nl.error_message, nl.created_at - FROM time_tracking_notification_log nl - WHERE nl.brand_id = p_brand_id - ORDER BY nl.created_at DESC - LIMIT p_limit; -END; -$$ LANGUAGE plpgsql; - --- ── RPC: check_and_notify_overtime ──────────────────────────────────────── --- Call this after a worker clocks in or out. --- Returns: { sent: boolean, triggers: text[], message: text } - -CREATE OR REPLACE FUNCTION check_and_notify_overtime( - p_brand_id UUID, - p_worker_id UUID, - p_worker_name TEXT, - p_daily_hours NUMERIC(8,2), - p_weekly_hours NUMERIC(8,2) -) -RETURNS TABLE (sent BOOLEAN, trigger_type TEXT, message TEXT) -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - settings_rec time_tracking_settings%ROWTYPE; - triggered TEXT[]; - email_body TEXT; - sms_body TEXT; - emails_arr TEXT[]; - sms_arr TEXT[]; - threshold_hrs NUMERIC(8,2); - actual_hrs NUMERIC(8,2); - notify_err TEXT; -BEGIN - -- Fetch settings (create default if missing) - SELECT * INTO settings_rec FROM time_tracking_settings WHERE brand_id = p_brand_id; - IF NOT FOUND THEN - INSERT INTO time_tracking_settings (brand_id) VALUES (p_brand_id) - RETURNING * INTO settings_rec; - END IF; - - -- Nothing to do if notifications disabled - IF NOT settings_rec.overtime_notifications THEN - RETURN QUERY SELECT false, ''::TEXT, 'notifications disabled'::TEXT; - RETURN; - END IF; - - triggered := ARRAY[]::TEXT[]; - - -- ── Daily threshold check ──────────────────────────────────────────── - IF settings_rec.enable_daily_alerts AND p_daily_hours > 0 THEN - threshold_hrs := settings_rec.daily_overtime_threshold; - actual_hrs := p_daily_hours; - - -- Reached daily overtime - IF actual_hrs >= threshold_hrs THEN - triggered := array_append(triggered, 'daily_reached'); - -- Approaching daily overtime (above threshold * daily_alert_threshold) - ELSIF actual_hrs >= (threshold_hrs * settings_rec.daily_alert_threshold) THEN - triggered := array_append(triggered, 'daily_approaching'); - END IF; - END IF; - - -- ── Weekly threshold check ─────────────────────────────────────────── - IF settings_rec.enable_weekly_alerts AND p_weekly_hours > 0 THEN - threshold_hrs := settings_rec.weekly_overtime_threshold; - actual_hrs := p_weekly_hours; - - IF actual_hrs >= threshold_hrs THEN - triggered := array_append(triggered, 'weekly_reached'); - ELSIF actual_hrs >= (threshold_hrs * settings_rec.weekly_alert_threshold) THEN - triggered := array_append(triggered, 'weekly_approaching'); - END IF; - END IF; - - -- Nothing triggered - IF array_length(triggered, 1) IS NULL THEN - RETURN QUERY SELECT false, ''::TEXT, 'no trigger'::TEXT; - RETURN; - END IF; - - -- ── Send notifications ───────────────────────────────────────────────── - emails_arr := COALESCE(settings_rec.notification_emails, ARRAY[]::TEXT[]); - sms_arr := COALESCE(settings_rec.notification_sms_numbers, ARRAY[]::TEXT[]); - - -- Build email body - email_body := format( - 'Time Tracking Alert — %s\n\nWorker: %s\nBrand: %s\n\nAlerts triggered:\n%s\n\nDaily hours: %.1f / %.1f threshold\nWeekly hours: %.1f / %.1f threshold\n\nLog in to view: %s', - settings_rec.brand_name, - p_worker_name, - settings_rec.brand_name, - array_to_string(triggered, E'\n'), - p_daily_hours, settings_rec.daily_overtime_threshold, - p_weekly_hours, settings_rec.weekly_overtime_threshold, - 'https://route-commerce-platform.vercel.app/admin/time-tracking' - ); - - -- Log entry (we do notification sending via app layer, so just record triggers) - INSERT INTO time_tracking_notification_log - (brand_id, worker_id, worker_name, trigger_type, threshold_hours, actual_hours, emails_sent, sms_numbers_sent, email_sent, sms_sent) - VALUES - (p_brand_id, p_worker_id, p_worker_name, triggered[1], - CASE triggered[1] - WHEN 'daily_reached' THEN settings_rec.daily_overtime_threshold - WHEN 'daily_approaching' THEN settings_rec.daily_overtime_threshold - WHEN 'weekly_reached' THEN settings_rec.weekly_overtime_threshold - WHEN 'weekly_approaching' THEN settings_rec.weekly_overtime_threshold - ELSE NULL - END, - actual_hrs, - emails_arr, sms_arr, - false, false); -- email/sms sent tracked by app layer after Resend/Twilio calls - - RETURN QUERY SELECT true, triggered[1], email_body::TEXT; -END; -$$ LANGUAGE plpgsql; - --- ── RPC: send_end_of_period_notification ─────────────────────────────────── - -CREATE OR REPLACE FUNCTION send_time_tracking_period_summary( - p_brand_id UUID -) -RETURNS TABLE (sent BOOLEAN, recipient TEXT, message TEXT) -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - settings_rec time_tracking_settings%ROWTYPE; - emails_arr TEXT[]; - brand_name TEXT; - summary_html TEXT; - period_start DATE; - period_end DATE; - total_hrs NUMERIC(10,2); - worker_count INTEGER; -BEGIN - SELECT * INTO settings_rec FROM time_tracking_settings WHERE brand_id = p_brand_id; - IF NOT FOUND OR NOT settings_rec.send_end_of_period_summary THEN - RETURN QUERY SELECT false, ''::TEXT, 'disabled or not found'::TEXT; - RETURN; - END IF; - - emails_arr := COALESCE(settings_rec.notification_emails, ARRAY[]::TEXT[]); - IF array_length(emails_arr, 1) IS NULL THEN - RETURN QUERY SELECT false, ''::TEXT, 'no recipients'::TEXT; - RETURN; - END IF; - - -- Compute pay period bounds - period_end := current_date; - period_start := period_end - (settings_rec.pay_period_length_days - 1)::INTEGER; - - -- Get total hours from worker_time_logs in period - SELECT COALESCE(SUM(total_minutes), 0) / 60.0 INTO total_hrs - FROM worker_time_logs wl - JOIN time_tracking_workers w ON w.id = wl.worker_id - WHERE w.brand_id = p_brand_id - AND wl.clock_in >= period_start - AND wl.clock_in < period_end + 1; - - SELECT COUNT(DISTINCT wl.worker_id) INTO worker_count - FROM worker_time_logs wl - JOIN time_tracking_workers w ON w.id = wl.worker_id - WHERE w.brand_id = p_brand_id - AND wl.clock_in >= period_start - AND wl.clock_in < period_end + 1; - - -- Log the notification - INSERT INTO time_tracking_notification_log - (brand_id, trigger_type, threshold_hours, actual_hours, emails_sent, email_sent) - VALUES - (p_brand_id, 'end_of_period_summary', - total_hrs, NULL, - emails_arr, false); - - RETURN QUERY SELECT true, emails_arr[1], format( - 'Pay Period Summary: %s — %s\nTotal hours: %s\nWorkers active: %s', - period_start, period_end, total_hrs, worker_count - )::TEXT; -END; -$$ LANGUAGE plpgsql; - --- ── Update get_time_tracking_settings to include new columns ─────────────── - -CREATE OR REPLACE FUNCTION get_time_tracking_settings(p_brand_id UUID) -RETURNS TABLE ( - id UUID, - brand_id UUID, - pay_period_start_day INTEGER, - pay_period_length_days INTEGER, - daily_overtime_threshold NUMERIC(5,2), - weekly_overtime_threshold NUMERIC(5,2), - overtime_multiplier NUMERIC(4,2), - overtime_notifications BOOLEAN, - notification_emails TEXT[], - notification_sms_numbers TEXT[], - enable_daily_alerts BOOLEAN, - enable_weekly_alerts BOOLEAN, - daily_alert_threshold NUMERIC(5,2), - weekly_alert_threshold NUMERIC(5,2), - send_end_of_period_summary BOOLEAN, - brand_name TEXT, - created_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ -) -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN QUERY - SELECT s.id, s.brand_id, s.pay_period_start_day, s.pay_period_length_days, - s.daily_overtime_threshold, s.weekly_overtime_threshold, - s.overtime_multiplier, s.overtime_notifications, - s.notification_emails, s.notification_sms_numbers, - s.enable_daily_alerts, s.enable_weekly_alerts, - s.daily_alert_threshold, s.weekly_alert_threshold, - s.send_end_of_period_summary, s.brand_name, - s.created_at, s.updated_at - FROM time_tracking_settings s WHERE s.brand_id = p_brand_id; -END; -$$ LANGUAGE plpgsql; - --- ── Update update_time_tracking_settings to handle new columns ───────────── - -CREATE OR REPLACE FUNCTION update_time_tracking_settings( - p_brand_id UUID, - p_pay_period_start_day INTEGER, - p_pay_period_length_days INTEGER, - p_daily_threshold NUMERIC(5,2), - p_weekly_threshold NUMERIC(5,2), - p_overtime_multiplier NUMERIC(4,2), - p_overtime_notifications BOOLEAN, - p_notification_emails TEXT[] DEFAULT NULL, - p_notification_sms_numbers TEXT[] DEFAULT NULL, - p_enable_daily_alerts BOOLEAN DEFAULT NULL, - p_enable_weekly_alerts BOOLEAN DEFAULT NULL, - p_daily_alert_threshold NUMERIC(5,2) DEFAULT NULL, - p_weekly_alert_threshold NUMERIC(5,2) DEFAULT NULL, - p_send_end_of_period_summary BOOLEAN DEFAULT NULL, - p_brand_name TEXT DEFAULT NULL -) -RETURNS BOOLEAN -SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE time_tracking_settings - SET - pay_period_start_day = p_pay_period_start_day, - pay_period_length_days = p_pay_period_length_days, - daily_overtime_threshold = p_daily_threshold, - weekly_overtime_threshold = p_weekly_threshold, - overtime_multiplier = p_overtime_multiplier, - overtime_notifications = p_overtime_notifications, - notification_emails = COALESCE(p_notification_emails, notification_emails), - notification_sms_numbers = COALESCE(p_notification_sms_numbers, notification_sms_numbers), - enable_daily_alerts = COALESCE(p_enable_daily_alerts, enable_daily_alerts), - enable_weekly_alerts = COALESCE(p_enable_weekly_alerts, enable_weekly_alerts), - daily_alert_threshold = COALESCE(p_daily_alert_threshold, daily_alert_threshold), - weekly_alert_threshold = COALESCE(p_weekly_alert_threshold, weekly_alert_threshold), - send_end_of_period_summary = COALESCE(p_send_end_of_period_summary, send_end_of_period_summary), - brand_name = COALESCE(p_brand_name, brand_name), - updated_at = now() - WHERE brand_id = p_brand_id; - - RETURN true; -END; -$$ LANGUAGE plpgsql; - -COMMIT; \ No newline at end of file diff --git a/supabase/migrations/.archived/134_abandoned_cart_recovery.sql b/supabase/migrations/.archived/134_abandoned_cart_recovery.sql deleted file mode 100644 index 8559ec1..0000000 --- a/supabase/migrations/.archived/134_abandoned_cart_recovery.sql +++ /dev/null @@ -1,123 +0,0 @@ --- Abandoned Cart Recovery tracking table -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, - contact_email TEXT NOT NULL, - contact_name TEXT, - cart_snapshot JSONB NOT NULL, -- { items: [{name, quantity, unit_price}], subtotal, item_count } - brand_name TEXT, - locale TEXT DEFAULT 'en', - - -- Sequence tracking - sequence_step INTEGER DEFAULT 0, -- 0=detected, 1=email1_sent, 2=email2_sent, 3=email3_sent - last_email_sent_at TIMESTAMPTZ, - next_email_at TIMESTAMPTZ, - - -- Resolution - status TEXT DEFAULT 'active' CHECK (status IN ('active', 'recovered', 'expired', 'manually_closed')), - recovered_order_id UUID REFERENCES wholesale_orders(id) ON DELETE SET NULL, - recovered_at TIMESTAMPTZ, - expired_at TIMESTAMPTZ, - manually_closed_at TIMESTAMPTZ, - manually_closed_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, - - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now() -); - -CREATE INDEX abandoned_cart_recovery_brand_id_idx ON abandoned_cart_recovery(brand_id); -CREATE INDEX abandoned_cart_recovery_status_idx ON abandoned_cart_recovery(status); -CREATE INDEX abandoned_cart_recovery_next_email_at_idx ON abandoned_cart_recovery(next_email_at) WHERE status = 'active'; - --- Welcome Email Sequence tracking table -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, - contact_email TEXT NOT NULL, - contact_name TEXT, - brand_name TEXT, - locale TEXT DEFAULT 'en', - - -- Sequence tracking - sequence_step INTEGER DEFAULT 0, -- 0=enrolled, 1=welcome_sent, 2=email2_sent, 3=email3_sent, 4=final_sent - last_email_sent_at TIMESTAMPTZ, - next_email_at TIMESTAMPTZ, - - -- Resolution - status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed', 'unsubscribed', 'bounced')), - completed_at TIMESTAMPTZ, - unsubscribed_at TIMESTAMPTZ, - bounced_at TIMESTAMPTZ, - - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now() -); - -CREATE INDEX welcome_email_sequence_brand_id_idx ON welcome_email_sequence(brand_id); -CREATE INDEX welcome_email_sequence_status_idx ON welcome_email_sequence(status); -CREATE INDEX welcome_email_sequence_next_email_at_idx ON welcome_email_sequence(next_email_at) WHERE status = 'active'; - --- Function to enroll contact in welcome sequence -CREATE OR REPLACE FUNCTION enroll_welcome_sequence(p_brand_id UUID, p_contact_id UUID, p_email TEXT, p_name TEXT DEFAULT NULL, p_locale TEXT DEFAULT 'en') -RETURNS UUID -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_id UUID; - v_brand_name TEXT; -BEGIN - SELECT name INTO v_brand_name FROM brands WHERE id = p_brand_id; - - INSERT INTO welcome_email_sequence (brand_id, contact_id, contact_email, contact_name, brand_name, locale, sequence_step, next_email_at, status) - VALUES (p_brand_id, p_contact_id, p_email, p_name, v_brand_name, p_locale, 0, now() + INTERVAL '1 hour', 'active') - ON CONFLICT DO NOTHING - RETURNING id INTO v_id; - - RETURN v_id; -END; -$$; - --- Function to get abandoned carts for a brand (detects recoverable abandoned carts) -CREATE OR REPLACE FUNCTION detect_abandoned_wholesale_carts(p_brand_id UUID) -RETURNS TABLE( - order_id UUID, - customer_id UUID, - contact_email TEXT, - contact_name TEXT, - cart_snapshot JSONB, - checkout_session_id TEXT, - created_at TIMESTAMPTZ, - minutes_elapsed NUMERIC -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - wo.id AS order_id, - wo.customer_id, - wc.email AS contact_email, - COALESCE(wc.contact_name, wc.company_name) AS contact_name, - wo.cart_snapshot, - wo.checkout_session_id, - wo.created_at, - EXTRACT(EPOCH FROM (now() - wo.created_at)) / 60 AS minutes_elapsed - FROM wholesale_orders wo - JOIN wholesale_customers wc ON wc.id = wo.customer_id - LEFT JOIN abandoned_cart_recovery acr ON acr.recovered_order_id = wo.id AND acr.status = 'recovered' - WHERE wo.brand_id = p_brand_id - AND wo.payment_status = 'pending' - AND wo.status NOT IN ('cancelled', ' fulfilled', 'recovered') - AND wo.checkout_session_id IS NOT NULL - AND acr.id IS NULL - AND wo.created_at > now() - INTERVAL '7 days' - ORDER BY wo.created_at ASC; -END; -$$; - --- Updated wholesale_orders to include cart_snapshot -ALTER TABLE wholesale_orders ADD COLUMN IF NOT EXISTS cart_snapshot JSONB; diff --git a/supabase/migrations/.archived/135_email_automation_rpcs.sql b/supabase/migrations/.archived/135_email_automation_rpcs.sql deleted file mode 100644 index 467f6de..0000000 --- a/supabase/migrations/.archived/135_email_automation_rpcs.sql +++ /dev/null @@ -1,226 +0,0 @@ --- Abandoned cart RPCs - -CREATE OR REPLACE FUNCTION get_abandoned_carts(p_brand_id UUID) -RETURNS TABLE(carts JSONB) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT jsonb_agg(jsonb_build_object( - 'id', acr.id, - 'brand_id', acr.brand_id, - 'customer_id', acr.customer_id, - 'contact_email', acr.contact_email, - 'contact_name', acr.contact_name, - 'cart_snapshot', acr.cart_snapshot, - 'brand_name', acr.brand_name, - 'locale', acr.locale, - 'sequence_step', acr.sequence_step, - 'last_email_sent_at', acr.last_email_sent_at, - 'next_email_at', acr.next_email_at, - 'status', acr.status, - 'recovered_order_id', acr.recovered_order_id, - 'recovered_at', acr.recovered_at, - 'created_at', acr.created_at - ) ORDER BY acr.created_at DESC) - FROM abandoned_cart_recovery acr - WHERE acr.brand_id = p_brand_id; -END; -$$; - -CREATE OR REPLACE FUNCTION enroll_abandoned_cart( - p_brand_id UUID, - p_customer_id UUID, - p_contact_email TEXT, - p_contact_name TEXT, - p_cart_snapshot JSONB, - p_brand_name TEXT, - p_locale TEXT DEFAULT 'en', - p_next_email_at TIMESTAMPTZ -) -RETURNS UUID -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_id UUID; -BEGIN - INSERT INTO abandoned_cart_recovery (brand_id, customer_id, contact_email, contact_name, cart_snapshot, brand_name, locale, next_email_at, status) - VALUES (p_brand_id, p_customer_id, p_contact_email, p_contact_name, p_cart_snapshot, p_brand_name, p_locale, p_next_email_at, 'active') - ON CONFLICT DO NOTHING - RETURNING id INTO v_id; - RETURN v_id; -END; -$$; - -CREATE OR REPLACE FUNCTION get_active_abandoned_carts(p_brand_id UUID) -RETURNS TABLE(carts JSONB) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT jsonb_agg(jsonb_build_object( - 'id', acr.id, - 'brand_id', acr.brand_id, - 'customer_id', acr.customer_id, - 'contact_email', acr.contact_email, - 'contact_name', acr.contact_name, - 'cart_snapshot', acr.cart_snapshot, - 'brand_name', acr.brand_name, - 'locale', acr.locale, - 'sequence_step', acr.sequence_step, - 'last_email_sent_at', acr.last_email_sent_at, - 'next_email_at', acr.next_email_at, - 'status', acr.status, - 'created_at', acr.created_at - ) ORDER BY acr.next_email_at ASC) - FROM abandoned_cart_recovery acr - WHERE acr.brand_id = p_brand_id - AND acr.status = 'active' - AND acr.next_email_at IS NOT NULL - AND acr.next_email_at <= now(); -END; -$$; - -CREATE OR REPLACE FUNCTION update_abandoned_cart( - p_id UUID, - p_sequence_step INTEGER DEFAULT NULL, - p_last_email_sent_at TIMESTAMPTZ DEFAULT NULL, - p_next_email_at TIMESTAMPTZ DEFAULT NULL, - p_status TEXT DEFAULT NULL, - p_expired_at TIMESTAMPTZ DEFAULT NULL, - p_manually_closed_by UUID DEFAULT NULL, - p_recovered_order_id UUID DEFAULT NULL, - p_recovered_at TIMESTAMPTZ DEFAULT NULL -) -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - UPDATE abandoned_cart_recovery SET - sequence_step = COALESCE(p_sequence_step, sequence_step), - last_email_sent_at = COALESCE(p_last_email_sent_at, last_email_sent_at), - next_email_at = COALESCE(p_next_email_at, next_email_at), - status = COALESCE(p_status, status), - expired_at = COALESCE(p_expired_at, expired_at), - manually_closed_at = CASE WHEN p_manually_closed_by IS NOT NULL THEN now() ELSE manually_closed_at END, - manually_closed_by = COALESCE(p_manually_closed_by, manually_closed_by), - recovered_order_id = COALESCE(p_recovered_order_id, recovered_order_id), - recovered_at = COALESCE(p_recovered_at, recovered_at), - updated_at = now() - WHERE id = p_id; -END; -$$; - --- Welcome sequence RPCs - -CREATE OR REPLACE FUNCTION get_welcome_sequence(p_brand_id UUID) -RETURNS TABLE(entries JSONB) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT jsonb_agg(jsonb_build_object( - 'id', ws.id, - 'brand_id', ws.brand_id, - 'contact_id', ws.contact_id, - 'contact_email', ws.contact_email, - 'contact_name', ws.contact_name, - 'brand_name', ws.brand_name, - 'locale', ws.locale, - 'sequence_step', ws.sequence_step, - 'last_email_sent_at', ws.last_email_sent_at, - 'next_email_at', ws.next_email_at, - 'status', ws.status, - 'created_at', ws.created_at - ) ORDER BY ws.created_at DESC) - FROM welcome_email_sequence ws - WHERE ws.brand_id = p_brand_id; -END; -$$; - -CREATE OR REPLACE FUNCTION get_active_welcome_sequence(p_brand_id UUID) -RETURNS TABLE(entries JSONB) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT jsonb_agg(jsonb_build_object( - 'id', ws.id, - 'brand_id', ws.brand_id, - 'contact_id', ws.contact_id, - 'contact_email', ws.contact_email, - 'contact_name', ws.contact_name, - 'brand_name', ws.brand_name, - 'locale', ws.locale, - 'sequence_step', ws.sequence_step, - 'last_email_sent_at', ws.last_email_sent_at, - 'next_email_at', ws.next_email_at, - 'status', ws.status, - 'created_at', ws.created_at - ) ORDER BY ws.next_email_at ASC) - FROM welcome_email_sequence ws - WHERE ws.brand_id = p_brand_id - AND ws.status = 'active' - AND ws.next_email_at IS NOT NULL - AND ws.next_email_at <= now(); -END; -$$; - -CREATE OR REPLACE FUNCTION update_welcome_sequence( - p_id UUID, - p_sequence_step INTEGER DEFAULT NULL, - p_last_email_sent_at TIMESTAMPTZ DEFAULT NULL, - p_next_email_at TIMESTAMPTZ DEFAULT NULL, - p_status TEXT DEFAULT NULL, - p_completed_at TIMESTAMPTZ DEFAULT NULL, - p_unsubscribed_at TIMESTAMPTZ DEFAULT NULL, - p_bounced_at TIMESTAMPTZ DEFAULT NULL -) -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - UPDATE welcome_email_sequence SET - sequence_step = COALESCE(p_sequence_step, sequence_step), - last_email_sent_at = COALESCE(p_last_email_sent_at, last_email_sent_at), - next_email_at = COALESCE(p_next_email_at, next_email_at), - status = COALESCE(p_status, status), - completed_at = COALESCE(p_completed_at, completed_at), - unsubscribed_at = COALESCE(p_unsubscribed_at, unsubscribed_at), - bounced_at = COALESCE(p_bounced_at, bounced_at), - updated_at = now() - WHERE id = p_id; -END; -$$; - --- Trigger: auto-enroll new wholesale customer contacts into welcome sequence -CREATE OR REPLACE FUNCTION on_new_wholesale_customer_contact() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - -- Check if contact has email and opted in - IF NEW.email IS NOT NULL AND NEW.email_opt_in = TRUE THEN - PERFORM enroll_welcome_sequence( - NEW.brand_id, - NEW.id, - NEW.email, - COALESCE(NEW.first_name, NEW.full_name, NEW.contact_name), - 'en' - ); - END IF; - RETURN NEW; -END; -$$; - --- Note: This trigger would be added to communication_contacts but requires --- source tracking. For now, enrollment is handled manually or via the --- communications subscription UI. diff --git a/supabase/migrations/.archived/136_email_automation_missing_rpcs.sql b/supabase/migrations/.archived/136_email_automation_missing_rpcs.sql deleted file mode 100644 index f7337c2..0000000 --- a/supabase/migrations/.archived/136_email_automation_missing_rpcs.sql +++ /dev/null @@ -1,257 +0,0 @@ --- 136_email_automation_missing_rpcs --- Missing: enroll_welcome_sequence, detect_abandoned_wholesale_carts --- Also adds: rate limiting for cron endpoints, cron secret validation - --- ── 1. enroll_welcome_sequence ──────────────────────────────────────────────── --- Auto-enroll new contacts (who have email_opt_in = TRUE) into the welcome sequence. --- Trigger fires when communication_contacts is created via subscription UI. - -CREATE OR REPLACE FUNCTION enroll_welcome_sequence( - p_brand_id UUID, - p_contact_id UUID, - p_email TEXT, - p_name TEXT DEFAULT NULL, - p_locale TEXT DEFAULT 'en' -) -RETURNS UUID -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_id UUID; - v_brand_name TEXT; -BEGIN - -- Check not already enrolled - IF EXISTS ( - SELECT 1 FROM welcome_email_sequence - WHERE contact_id = p_contact_id AND brand_id = p_brand_id - AND status IN ('active', 'completed') - ) THEN - RETURN NULL; - END IF; - - SELECT name INTO v_brand_name FROM brands WHERE id = p_brand_id; - - INSERT INTO welcome_email_sequence - (brand_id, contact_id, contact_email, contact_name, brand_name, locale, sequence_step, next_email_at, status) - VALUES - (p_brand_id, p_contact_id, p_email, p_name, v_brand_name, p_locale, 0, now() + INTERVAL '1 hour', 'active') - ON CONFLICT (contact_id, brand_id) DO NOTHING - RETURNING id INTO v_id; - - RETURN v_id; -END; -$$; - --- ── 2. detect_abandoned_wholesale_carts ─────────────────────────────────────── --- Find wholesale orders that look abandoned: pending payment, no cancellation, --- no recovered flag, created within last 7 days, no checkout completed. --- Returns rows that are not yet enrolled in the recovery system. - -CREATE OR REPLACE FUNCTION detect_abandoned_wholesale_carts(p_brand_id UUID) -RETURNS TABLE( - order_id UUID, - customer_id UUID, - contact_email TEXT, - contact_name TEXT, - cart_snapshot JSONB, - checkout_session_id TEXT, - created_at TIMESTAMPTZ, - minutes_elapsed NUMERIC -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN QUERY - SELECT - wo.id AS order_id, - wo.customer_id, - wc.email AS contact_email, - COALESCE(wc.first_name, wc.full_name, wc.company_name) AS contact_name, - wo.cart_snapshot, - wo.checkout_session_id, - wo.created_at, - EXTRACT(EPOCH FROM (now() - wo.created_at)) / 60 AS minutes_elapsed - FROM wholesale_orders wo - JOIN wholesale_customers wc ON wc.id = wo.customer_id - WHERE wo.brand_id = p_brand_id - AND wo.payment_status = 'pending' - AND wo.status NOT IN ('cancelled', 'fulfilled', 'completed', 'recovered') - AND wo.checkout_session_id IS NOT NULL - -- not already tracked - AND NOT EXISTS ( - SELECT 1 FROM abandoned_cart_recovery acr - WHERE acr.recovered_order_id = wo.id AND acr.status IN ('active', 'recovered', 'expired') - ) - AND NOT EXISTS ( - SELECT 1 FROM abandoned_cart_recovery acr - WHERE acr.contact_email = wc.email - AND acr.brand_id = p_brand_id - AND acr.status IN ('active', 'recovered') - AND acr.created_at > now() - INTERVAL '48 hours' - ) - AND wo.created_at > now() - INTERVAL '7 days' - ORDER BY wo.created_at ASC; -END; -$$; - --- ── 3. Sync wholesale_orders cart_snapshot if missing ───────────────────────── --- Pre-populate cart_snapshot from order_items for orders created before the column was added. - -CREATE OR REPLACE FUNCTION sync_wholesale_order_cart_snapshot(p_order_id UUID) -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - UPDATE wholesale_orders wo - SET cart_snapshot = ( - SELECT jsonb_build_object( - 'items', jsonb_agg(jsonb_build_object( - 'name', p.name, - 'quantity', oi.quantity, - 'unit_price', COALESCE(oi.unit_price, p.wholesale_price) - )), - 'subtotal', SUM(COALESCE(oi.unit_price, p.wholesale_price) * oi.quantity), - 'item_count', COUNT(oi.id) - ) - FROM order_items oi - JOIN products p ON p.id = oi.product_id - WHERE oi.order_id = p_order_id - ) - WHERE wo.id = p_order_id - AND (wo.cart_snapshot IS NULL OR wo.cart_snapshot = 'null'::jsonb); -END; -$$; - --- Apply for recent orders without snapshots (last 30 days) -DO $$ -DECLARE - r RECORD; -BEGIN - FOR r IN - SELECT id FROM wholesale_orders - WHERE cart_snapshot IS NULL - AND created_at > now() - INTERVAL '30 days' - LOOP - PERFORM sync_wholesale_order_cart_snapshot(r.id); - END LOOP; -END; -$$; - --- ── 4. Enroll existing pending wholesale customers into welcome sequence ───────── --- Run this once to backfill welcome sequence for existing contacts. - -CREATE OR REPLACE FUNCTION backfill_welcome_sequence(p_brand_id UUID) -RETURNS INTEGER -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_count INTEGER := 0; -BEGIN - INSERT INTO welcome_email_sequence - (brand_id, contact_id, contact_email, contact_name, brand_name, locale, sequence_step, next_email_at, status) - SELECT - c.brand_id, - c.id, - c.email, - COALESCE(c.first_name, c.full_name), - b.name, - 'en', - 0, - now() + INTERVAL '1 hour', - 'active' - FROM communication_contacts c - JOIN brands b ON b.id = c.brand_id - WHERE c.brand_id = p_brand_id - AND c.email IS NOT NULL - AND c.email_opt_in = TRUE - AND NOT EXISTS ( - SELECT 1 FROM welcome_email_sequence ws - WHERE ws.contact_id = c.id AND ws.brand_id = p_brand_id - ) - ON CONFLICT DO NOTHING; - GET DIAGNOSTICS v_count = ROW_COUNT; - RETURN v_count; -END; -$$; - --- ── 5. RPC: manual_resend_abandoned_cart_email ────────────────────────────────── --- Lets admin manually trigger a resend for a specific cart. - -CREATE OR REPLACE FUNCTION manual_resend_abandoned_cart_email(p_cart_id UUID, p_step INTEGER DEFAULT 1) -RETURNS BOOLEAN -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - UPDATE abandoned_cart_recovery - SET - sequence_step = p_step - 1, - next_email_at = now(), - status = 'active', - last_email_sent_at = NULL - WHERE id = p_cart_id - AND status IN ('active', 'expired'); - RETURN TRUE; -END; -$$; - --- ── 6. RPC: manual_resend_welcome_email ───────────────────────────────────────── - -CREATE OR REPLACE FUNCTION manual_resend_welcome_email(p_entry_id UUID, p_step INTEGER DEFAULT 1) -RETURNS BOOLEAN -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - UPDATE welcome_email_sequence - SET - sequence_step = p_step - 1, - next_email_at = now(), - status = 'active', - last_email_sent_at = NULL - WHERE id = p_entry_id - AND status IN ('active', 'completed', 'bounced'); - RETURN TRUE; -END; -$$; - --- ── 7. Cron job: clean up stale expired carts ──────────────────────────────────── --- Mark carts as expired if next_email_at was more than 7 days ago. - -CREATE OR REPLACE FUNCTION cleanup_stale_abandoned_carts() -RETURNS INTEGER -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_count INTEGER := 0; -BEGIN - UPDATE abandoned_cart_recovery - SET status = 'expired', expired_at = now() - WHERE status = 'active' - AND next_email_at < now() - INTERVAL '7 days'; - GET DIAGNOSTICS v_count = ROW_COUNT; - RETURN v_count; -END; -$$; - --- ── 8. Grant execute to authenticated role ───────────────────────────────────── - -GRANT EXECUTE ON FUNCTION enroll_welcome_sequence TO authenticated; -GRANT EXECUTE ON FUNCTION detect_abandoned_wholesale_carts TO authenticated; -GRANT EXECUTE ON FUNCTION sync_wholesale_order_cart_snapshot TO authenticated; -GRANT EXECUTE ON FUNCTION backfill_welcome_sequence TO authenticated; -GRANT EXECUTE ON FUNCTION manual_resend_abandoned_cart_email TO authenticated; -GRANT EXECUTE ON FUNCTION manual_resend_welcome_email TO authenticated; -GRANT EXECUTE ON FUNCTION cleanup_stale_abandoned_carts TO authenticated; \ No newline at end of file diff --git a/supabase/migrations/.archived/137_time_tracking_workers_and_tasks.sql b/supabase/migrations/.archived/137_time_tracking_workers_and_tasks.sql deleted file mode 100644 index 5184d76..0000000 --- a/supabase/migrations/.archived/137_time_tracking_workers_and_tasks.sql +++ /dev/null @@ -1,193 +0,0 @@ --- Time tracking workers table -CREATE TABLE IF NOT EXISTS time_tracking_workers ( - id uuid DEFAULT gen_random_uuid() PRIMARY KEY, - brand_id uuid NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - name text NOT NULL, - role text DEFAULT 'worker', - lang text DEFAULT 'en', - pin text NOT NULL, - active boolean DEFAULT true, - last_used_at timestamptz, - created_at timestamptz DEFAULT now() -); - -CREATE UNIQUE INDEX IF NOT EXISTS time_tracking_workers_pin_brand ON time_tracking_workers(brand_id, pin) WHERE active = true; - --- Time tracking tasks table -CREATE TABLE IF NOT EXISTS time_tracking_tasks ( - id uuid DEFAULT gen_random_uuid() PRIMARY KEY, - brand_id uuid NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - name text NOT NULL, - name_es text, - unit text DEFAULT 'hours', - sort_order integer DEFAULT 0, - active boolean DEFAULT true, - created_at timestamptz DEFAULT now() -); - --- Workers RPCs -CREATE OR REPLACE FUNCTION create_time_worker( - p_brand_id uuid, - p_name text, - p_role text DEFAULT 'worker', - p_lang text DEFAULT 'en' -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_pin text; - v_worker time_tracking_workers; -BEGIN - v_pin := lpad(floor(random() * 10000)::text, 4, '0'); - - INSERT INTO time_tracking_workers (brand_id, name, role, lang, pin) - VALUES (p_brand_id, p_name, p_role, p_lang, v_pin) - RETURNING * INTO v_worker; - - RETURN json_build_object( - 'success', true, - 'worker', row_to_json(v_worker), - 'pin', v_pin - ); -END; -$$; - -CREATE OR REPLACE FUNCTION get_time_tracking_workers(p_brand_id uuid) -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN json_build_object( - 'workers', ( - SELECT json_agg(row_to_json(t)) - FROM ( - SELECT id, name, role, lang, active, last_used_at, created_at - FROM time_tracking_workers - WHERE brand_id = p_brand_id - ORDER BY name - ) t - ) - ); -END; -$$; - -CREATE OR REPLACE FUNCTION update_time_worker( - p_worker_id uuid, - p_name text, - p_role text, - p_lang text, - p_active boolean -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - UPDATE time_tracking_workers - SET name = p_name, role = p_role, lang = p_lang, active = p_active - WHERE id = p_worker_id; - - RETURN json_build_object('success', true); -END; -$$; - -CREATE OR REPLACE FUNCTION delete_time_worker(p_worker_id uuid) -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - DELETE FROM time_tracking_workers WHERE id = p_worker_id; - RETURN json_build_object('success', true); -END; -$$; - -CREATE OR REPLACE FUNCTION reset_time_worker_pin(p_worker_id uuid) -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_pin text; -BEGIN - v_pin := lpad(floor(random() * 10000)::text, 4, '0'); - UPDATE time_tracking_workers SET pin = v_pin WHERE id = p_worker_id; - RETURN json_build_object('success', true, 'pin', v_pin); -END; -$$; - --- Tasks RPCs -CREATE OR REPLACE FUNCTION get_time_tracking_tasks(p_brand_id uuid, p_active_only boolean DEFAULT false) -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN json_build_object( - 'tasks', ( - SELECT json_agg(row_to_json(t)) - FROM ( - SELECT id, name, name_es, unit, sort_order, active, created_at - FROM time_tracking_tasks - WHERE brand_id = p_brand_id - AND (NOT p_active_only OR active = true) - ORDER BY sort_order, name - ) t - ) - ); -END; -$$; - -CREATE OR REPLACE FUNCTION create_time_task( - p_brand_id uuid, - p_name text, - p_name_es text DEFAULT NULL, - p_unit text DEFAULT 'hours', - p_sort_order integer DEFAULT 0 -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_id uuid; -BEGIN - INSERT INTO time_tracking_tasks (brand_id, name, name_es, unit, sort_order) - VALUES (p_brand_id, p_name, p_name_es, p_unit, p_sort_order) - RETURNING id INTO v_id; - - RETURN json_build_object('success', true, 'id', v_id); -END; -$$; - -CREATE OR REPLACE FUNCTION update_time_task( - p_task_id uuid, - p_name text, - p_name_es text, - p_unit text, - p_active boolean, - p_sort_order integer -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - UPDATE time_tracking_tasks - SET name = p_name, name_es = p_name_es, unit = p_unit, - active = p_active, sort_order = p_sort_order - WHERE id = p_task_id; - - RETURN json_build_object('success', true); -END; -$$; - -CREATE OR REPLACE FUNCTION delete_time_task(p_task_id uuid) -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - DELETE FROM time_tracking_tasks WHERE id = p_task_id; - RETURN json_build_object('success', true); -END; -$$; diff --git a/supabase/migrations/.archived/138_time_tracking_logs.sql b/supabase/migrations/.archived/138_time_tracking_logs.sql deleted file mode 100644 index 8d707d7..0000000 --- a/supabase/migrations/.archived/138_time_tracking_logs.sql +++ /dev/null @@ -1,70 +0,0 @@ --- Time tracking logs table -CREATE TABLE IF NOT EXISTS time_tracking_logs ( - id uuid DEFAULT gen_random_uuid() PRIMARY KEY, - brand_id uuid NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - worker_id uuid NOT NULL REFERENCES time_tracking_workers(id) ON DELETE CASCADE, - task_id uuid REFERENCES time_tracking_tasks(id) ON DELETE SET NULL, - clock_in timestamptz NOT NULL, - clock_out timestamptz, - lunch_break_minutes integer DEFAULT 0, - notes text, - submitted_via text DEFAULT 'manual', - created_at timestamptz DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS time_tracking_logs_worker ON time_tracking_logs(worker_id); -CREATE INDEX IF NOT EXISTS time_tracking_logs_brand ON time_tracking_logs(brand_id); -CREATE INDEX IF NOT EXISTS time_tracking_logs_clock_in ON time_tracking_logs(clock_in); - --- get_worker_time_logs RPC -CREATE OR REPLACE FUNCTION get_worker_time_logs( - p_brand_id uuid, - p_worker_id uuid DEFAULT NULL, - p_task_id uuid DEFAULT NULL, - p_start timestamptz DEFAULT NULL, - p_end timestamptz DEFAULT NULL, - p_limit integer DEFAULT 200, - p_offset integer DEFAULT 0 -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN json_build_object( - 'logs', ( - SELECT json_agg(row_to_json(t)) - FROM ( - SELECT - l.id, - l.worker_id, - w.name AS worker_name, - l.task_id, - t.name AS task_name, - l.clock_in, - l.clock_out, - l.lunch_break_minutes, - l.notes, - l.submitted_via, - l.created_at, - CASE - WHEN l.clock_out IS NOT NULL THEN - (EXTRACT(EPOCH FROM (l.clock_out - l.clock_in))::integer / 60) - l.lunch_break_minutes - ELSE - (EXTRACT(EPOCH FROM (now() - l.clock_in))::integer / 60) - l.lunch_break_minutes - END AS total_minutes - FROM time_tracking_logs l - JOIN time_tracking_workers w ON w.id = l.worker_id - LEFT JOIN time_tracking_tasks t ON t.id = l.task_id - WHERE l.brand_id = p_brand_id - AND (p_worker_id IS NULL OR l.worker_id = p_worker_id) - AND (p_task_id IS NULL OR l.task_id = p_task_id) - AND (p_start IS NULL OR l.clock_in >= p_start) - AND (p_end IS NULL OR l.clock_in <= p_end) - ORDER BY l.clock_in DESC - LIMIT p_limit - OFFSET p_offset - ) t - ) - ); -END; -$$; diff --git a/supabase/migrations/.archived/139_time_tracking_missing_rpcs.sql b/supabase/migrations/.archived/139_time_tracking_missing_rpcs.sql deleted file mode 100644 index 5a0ee63..0000000 --- a/supabase/migrations/.archived/139_time_tracking_missing_rpcs.sql +++ /dev/null @@ -1,115 +0,0 @@ --- Update time log RPC -CREATE OR REPLACE FUNCTION update_worker_time_log( - p_log_id uuid, - p_task_name text, - p_clock_in timestamptz, - p_clock_out timestamptz DEFAULT NULL, - p_lunch_minutes integer DEFAULT 0, - p_notes text DEFAULT NULL -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_task_id uuid; -BEGIN - SELECT id INTO v_task_id FROM time_tracking_tasks WHERE name = p_task_name LIMIT 1; - - UPDATE time_tracking_logs - SET task_id = v_task_id, - clock_in = p_clock_in, - clock_out = p_clock_out, - lunch_break_minutes = p_lunch_minutes, - notes = p_notes - WHERE id = p_log_id; - - RETURN json_build_object('success', true); -END; -$$; - --- Delete time log RPC -CREATE OR REPLACE FUNCTION delete_worker_time_log(p_log_id uuid) -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - DELETE FROM time_tracking_logs WHERE id = p_log_id; - RETURN json_build_object('success', true); -END; -$$; - --- Get time tracking summary RPC -CREATE OR REPLACE FUNCTION get_time_tracking_summary( - p_brand_id uuid, - p_start timestamptz, - p_end timestamptz -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN ( - WITH worker_stats AS ( - SELECT - l.worker_id, - w.name AS worker_name, - COUNT(*) AS entry_count, - SUM( - CASE - WHEN l.clock_out IS NOT NULL THEN - (EXTRACT(EPOCH FROM (l.clock_out - l.clock_in))::integer / 60) - l.lunch_break_minutes - ELSE 0 - END - ) AS total_minutes - FROM time_tracking_logs l - JOIN time_tracking_workers w ON w.id = l.worker_id - WHERE l.brand_id = p_brand_id - AND l.clock_in >= p_start - AND l.clock_in <= p_end - GROUP BY l.worker_id, w.name - ), - task_stats AS ( - SELECT - l.task_id, - t.name AS task_name, - t.name_es AS task_name_es, - COUNT(*) AS entry_count, - SUM( - CASE - WHEN l.clock_out IS NOT NULL THEN - (EXTRACT(EPOCH FROM (l.clock_out - l.clock_in))::integer / 60) - l.lunch_break_minutes - ELSE 0 - END - ) AS total_minutes - FROM time_tracking_logs l - LEFT JOIN time_tracking_tasks t ON t.id = l.task_id - WHERE l.brand_id = p_brand_id - AND l.clock_in >= p_start - AND l.clock_in <= p_end - GROUP BY l.task_id, t.name, t.name_es - ), - totals AS ( - SELECT - COUNT(*) AS entry_count, - COALESCE(SUM( - CASE - WHEN l.clock_out IS NOT NULL THEN - (EXTRACT(EPOCH FROM (l.clock_out - l.clock_in))::integer / 60) - l.lunch_break_minutes - ELSE 0 - END - ), 0) AS total_minutes, - COUNT(*) FILTER (WHERE l.clock_out IS NULL) AS open_count - FROM time_tracking_logs l - WHERE l.brand_id = p_brand_id - AND l.clock_in >= p_start - AND l.clock_in <= p_end - ) - SELECT json_build_object( - 'by_worker', (SELECT json_agg(json_build_object('id', worker_id, 'name', worker_name, 'entry_count', entry_count, 'total_hours', ROUND(total_minutes::numeric / 60, 2))) FROM worker_stats), - 'by_task', (SELECT json_agg(json_build_object('id', task_id, 'name', task_name, 'name_es', task_name_es, 'entry_count', entry_count, 'total_hours', ROUND(total_minutes::numeric / 60, 2))) FROM task_stats), - 'totals', (SELECT json_build_object('entry_count', entry_count, 'total_hours', ROUND(total_minutes::numeric / 60, 2), 'open_count', open_count) FROM totals) - ) - ); -END; -$$; diff --git a/supabase/migrations/.archived/140_time_tracking_field_rpcs.sql b/supabase/migrations/.archived/140_time_tracking_field_rpcs.sql deleted file mode 100644 index 94e9a35..0000000 --- a/supabase/migrations/.archived/140_time_tracking_field_rpcs.sql +++ /dev/null @@ -1,264 +0,0 @@ --- verify_time_tracking_pin RPC --- Looks up worker by brand_id + pin, creates a session token, returns worker info -CREATE OR REPLACE FUNCTION verify_time_tracking_pin( - p_brand_id uuid, - p_pin text -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_worker time_tracking_workers%ROWTYPE; - v_session_id text; - v_expires_at timestamptz; -BEGIN - SELECT * INTO v_worker - FROM time_tracking_workers - WHERE brand_id = p_brand_id AND pin = p_pin AND active = true - LIMIT 1; - - IF v_worker IS NULL THEN - RETURN json_build_object('success', false, 'error', 'Invalid PIN'); - END IF; - - -- Update last_used_at - UPDATE time_tracking_workers SET last_used_at = now() WHERE id = v_worker.id; - - -- Generate session - v_session_id := gen_random_uuid()::text; - v_expires_at := now() + interval '12 hours'; - - RETURN json_build_object( - 'success', true, - 'worker_id', v_worker.id, - 'name', v_worker.name, - 'role', v_worker.role, - 'lang', v_worker.lang, - 'brand_id', v_worker.brand_id, - 'session_id', v_session_id, - 'expires_at', v_expires_at - ); -END; -$$; - --- clock_in_worker RPC --- Creates a new time tracking log entry with clock_in set to now -CREATE OR REPLACE FUNCTION clock_in_worker( - p_brand_id uuid, - p_worker_id uuid, - p_task_id uuid DEFAULT NULL, - p_task_name text DEFAULT 'General Labor' -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_task_id uuid; - v_log_id uuid; -BEGIN - -- Resolve task_name to task_id if provided - IF p_task_id IS NULL AND p_task_name IS NOT NULL THEN - SELECT id INTO v_task_id FROM time_tracking_tasks WHERE brand_id = p_brand_id AND name = p_task_name LIMIT 1; - ELSE - v_task_id := p_task_id; - END IF; - - -- Check for open clock-in - IF EXISTS ( - SELECT 1 FROM time_tracking_logs - WHERE worker_id = p_worker_id AND clock_out IS NULL - ) THEN - RETURN json_build_object('success', false, 'error', 'Already clocked in'); - END IF; - - -- Create log entry - INSERT INTO time_tracking_logs (brand_id, worker_id, task_id, clock_in, submitted_via) - VALUES (p_brand_id, p_worker_id, v_task_id, now(), 'kiosk') - RETURNING id INTO v_log_id; - - RETURN json_build_object( - 'success', true, - 'log_id', v_log_id, - 'clock_in', now() - ); -END; -$$; - --- clock_out_worker RPC --- Closes the open clock-in for a worker, calculates total minutes -CREATE OR REPLACE FUNCTION clock_out_worker( - p_worker_id uuid, - p_lunch_minutes integer DEFAULT 0, - p_notes text DEFAULT NULL -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_log time_tracking_logs%ROWTYPE; - v_total_minutes integer; -BEGIN - -- Find open clock-in - SELECT * INTO v_log - FROM time_tracking_logs - WHERE worker_id = p_worker_id AND clock_out IS NULL - ORDER BY clock_in DESC - LIMIT 1; - - IF v_log IS NULL THEN - RETURN json_build_object('success', false, 'error', 'No open clock-in found'); - END IF; - - -- Calculate total minutes (excluding lunch) - v_total_minutes := (EXTRACT(EPOCH FROM (now() - v_log.clock_in))::integer / 60) - p_lunch_minutes; - IF v_total_minutes < 0 THEN v_total_minutes := 0; END IF; - - -- Update log - UPDATE time_tracking_logs - SET clock_out = now(), - lunch_break_minutes = p_lunch_minutes, - notes = p_notes - WHERE id = v_log.id; - - RETURN json_build_object( - 'success', true, - 'log_id', v_log.id, - 'clock_out', now(), - 'total_minutes', v_total_minutes - ); -END; -$$; - --- get_open_clock_in RPC --- Returns the open clock-in for a worker if exists -CREATE OR REPLACE FUNCTION get_open_clock_in( - p_worker_id uuid -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_log time_tracking_logs%ROWTYPE; - v_task_name text; - v_elapsed integer; -BEGIN - SELECT l.*, t.name AS task_name INTO v_log - FROM time_tracking_logs l - LEFT JOIN time_tracking_tasks t ON t.id = l.task_id - WHERE l.worker_id = p_worker_id AND l.clock_out IS NULL - ORDER BY l.clock_in DESC - LIMIT 1; - - IF v_log IS NULL THEN - RETURN json_build_object('success', true, 'open', false); - END IF; - - v_elapsed := (EXTRACT(EPOCH FROM (now() - v_log.clock_in))::integer / 60); - - RETURN json_build_object( - 'success', true, - 'open', true, - 'log_id', v_log.id, - 'task_name', v_log.task_name, - 'clock_in', v_log.clock_in, - 'elapsed_minutes', v_elapsed - ); -END; -$$; - --- get_worker_pay_period_hours RPC --- Calculates hours for current pay period (bi-weekly, starting from brand's pay_period_start) -CREATE OR REPLACE FUNCTION get_worker_pay_period_hours( - p_brand_id uuid, - p_worker_id uuid -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_period_start timestamptz; - v_period_end timestamptz; - v_total_minutes integer; - v_daily_minutes integer; - v_weekly_minutes integer; -BEGIN - -- Default to bi-weekly from beginning of current month - v_period_start := date_trunc('month', now()) + ((EXTRACT(DOW FROM date_trunc('month', now()))::integer + 6) % 7 || ' days')::interval; - v_period_end := v_period_start + interval '13 days'; - - -- If we're past end of period, roll forward - WHILE v_period_end < now() LOOP - v_period_start := v_period_start + interval '14 days'; - v_period_end := v_period_end + interval '14 days'; - END LOOP; - - -- Total minutes in period - SELECT COALESCE(SUM( - CASE - WHEN clock_out IS NOT NULL THEN - (EXTRACT(EPOCH FROM (clock_out - clock_in))::integer / 60) - lunch_break_minutes - ELSE 0 - END - ), 0) INTO v_total_minutes - FROM time_tracking_logs - WHERE worker_id = p_worker_id - AND clock_in >= v_period_start - AND clock_in <= v_period_end; - - -- Daily minutes (today) - SELECT COALESCE(SUM( - CASE - WHEN clock_out IS NOT NULL THEN - (EXTRACT(EPOCH FROM (clock_out - clock_in))::integer / 60) - lunch_break_minutes - ELSE 0 - END - ), 0) INTO v_daily_minutes - FROM time_tracking_logs - WHERE worker_id = p_worker_id - AND clock_in >= date_trunc('day', now()) - AND clock_in < date_trunc('day', now()) + interval '1 day'; - - -- Weekly minutes (current week starting Monday) - SELECT COALESCE(SUM( - CASE - WHEN clock_out IS NOT NULL THEN - (EXTRACT(EPOCH FROM (clock_out - clock_in))::integer / 60) - lunch_break_minutes - ELSE 0 - END - ), 0) INTO v_weekly_minutes - FROM time_tracking_logs - WHERE worker_id = p_worker_id - AND clock_in >= date_trunc('week', now()) - AND clock_in < date_trunc('week', now()) + interval '7 days'; - - RETURN json_build_object( - 'total_minutes', v_total_minutes, - 'total_hours', ROUND(v_total_minutes::numeric / 60, 2), - 'daily_minutes', v_daily_minutes, - 'daily_hours', ROUND(v_daily_minutes::numeric / 60, 2), - 'weekly_minutes', v_weekly_minutes, - 'weekly_hours', ROUND(v_weekly_minutes::numeric / 60, 2), - 'daily_overtime', v_daily_minutes > 720, - 'weekly_overtime', v_weekly_minutes > 3360, - 'period_start', v_period_start, - 'period_end', v_period_end - ); -END; -$$; - --- get_time_tracking_settings RPC -CREATE OR REPLACE FUNCTION get_time_tracking_settings( - p_brand_id uuid -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - -- Returns default settings; brand_settings integration can extend this later - RETURN json_build_object( - 'pay_period_type', 'biweekly', - 'daily_overtime_threshold', 12, - 'weekly_overtime_threshold', 56 - ); -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/141_update_user_password_rpc.sql b/supabase/migrations/.archived/141_update_user_password_rpc.sql deleted file mode 100644 index 42cd518..0000000 --- a/supabase/migrations/.archived/141_update_user_password_rpc.sql +++ /dev/null @@ -1,20 +0,0 @@ --- update_user_password RPC --- Accepts user_id (text) and new password, updates auth.users via supabase_admin, --- then clears must_change_password flag in admin_users. --- SECURITY DEFINER so it runs with service role privileges. -CREATE OR REPLACE FUNCTION update_user_password(p_user_id text, p_password text) -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - PERFORM supabase_admin.update_user(p_user_id, jsonb_build_object('password', p_password)); - - UPDATE admin_users - SET must_change_password = false - WHERE user_id = p_user_id - AND must_change_password = true; - - RETURN true; -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/.archived/142_integration_credentials.sql b/supabase/migrations/.archived/142_integration_credentials.sql deleted file mode 100644 index 0808c74..0000000 --- a/supabase/migrations/.archived/142_integration_credentials.sql +++ /dev/null @@ -1,85 +0,0 @@ --- Migration 142: Add integration credentials storage (Resend, Twilio) --- Store per-brand Resend and Twilio API credentials securely - -ALTER TABLE brand_settings - ADD COLUMN IF NOT EXISTS resend_credentials JSONB DEFAULT '{ - "api_key": null, - "from_email": null, - "from_name": null - }'::jsonb; - -ALTER TABLE brand_settings - ADD COLUMN IF NOT EXISTS twilio_credentials JSONB DEFAULT '{ - "account_sid": null, - "auth_token": null, - "phone_number": null - }'::jsonb; - --- RPC to get Resend credentials -CREATE OR REPLACE FUNCTION get_resend_credentials(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN COALESCE( - (SELECT resend_credentials FROM brand_settings WHERE brand_id = p_brand_id), - '{"api_key": null, "from_email": null, "from_name": null}'::jsonb - ); -END; -$$; - --- RPC to save Resend credentials -CREATE OR REPLACE FUNCTION set_resend_credentials( - p_brand_id UUID, - p_credentials JSONB -) -RETURNS BOOLEAN -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - UPDATE brand_settings - SET resend_credentials = p_credentials, - updated_at = now() - WHERE brand_id = p_brand_id; - RETURN true; -END; -$$; - --- RPC to get Twilio credentials -CREATE OR REPLACE FUNCTION get_twilio_credentials(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN COALESCE( - (SELECT twilio_credentials FROM brand_settings WHERE brand_id = p_brand_id), - '{"account_sid": null, "auth_token": null, "phone_number": null}'::jsonb - ); -END; -$$; - --- RPC to save Twilio credentials -CREATE OR REPLACE FUNCTION set_twilio_credentials( - p_brand_id UUID, - p_credentials JSONB -) -RETURNS BOOLEAN -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - UPDATE brand_settings - SET twilio_credentials = p_credentials, - updated_at = now() - WHERE brand_id = p_brand_id; - RETURN true; -END; -$$; - -GRANT EXECUTE ON FUNCTION get_resend_credentials(UUID) TO authenticated; -GRANT EXECUTE ON FUNCTION set_resend_credentials(UUID, JSONB) TO authenticated; -GRANT EXECUTE ON FUNCTION get_twilio_credentials(UUID) TO authenticated; -GRANT EXECUTE ON FUNCTION set_twilio_credentials(UUID, JSONB) TO authenticated; diff --git a/supabase/migrations/.archived/143_enable_route_trace.sql b/supabase/migrations/.archived/143_enable_route_trace.sql deleted file mode 100644 index 6998ce4..0000000 --- a/supabase/migrations/.archived/143_enable_route_trace.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Enable route_trace feature for the default brand --- This allows access to the Route Trace dashboard - -SELECT set_brand_feature('64294306-5f42-463d-a5e8-2ad6c81a96de', 'route_trace', true); diff --git a/supabase/migrations/.archived/144_time_tracking_worker_number_rls.sql b/supabase/migrations/.archived/144_time_tracking_worker_number_rls.sql deleted file mode 100644 index 9b79187..0000000 --- a/supabase/migrations/.archived/144_time_tracking_worker_number_rls.sql +++ /dev/null @@ -1,116 +0,0 @@ --- Add numeric worker_number (PIN) to time_tracking_workers --- Workers enter this 4-digit PIN on the time clock to identify themselves - -ALTER TABLE time_tracking_workers ADD COLUMN IF NOT EXISTS worker_number integer; - --- Auto-assign sequential worker numbers (PINs) per brand, starting at 100 -CREATE OR REPLACE FUNCTION assign_worker_number(p_brand_id uuid) -RETURNS integer -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_next_number integer; -BEGIN - -- Start at 100, find next available number per brand - SELECT COALESCE(MAX(worker_number), 99) + 1 - INTO v_next_number - FROM time_tracking_workers - WHERE brand_id = p_brand_id; - - RETURN v_next_number; -END; -$$; - --- Update create_time_worker to assign worker_number (PIN) -CREATE OR REPLACE FUNCTION create_time_worker( - p_brand_id uuid, - p_name text, - p_role text DEFAULT 'worker', - p_lang text DEFAULT 'en', - p_worker_number integer DEFAULT NULL -) RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_pin text; - v_number integer; - v_worker time_tracking_workers; -BEGIN - v_pin := lpad(floor(random() * 10000)::text, 4, '0'); - - -- Use provided number or assign next available - IF p_worker_number IS NOT NULL THEN - v_number := p_worker_number; - ELSE - v_number := assign_worker_number(p_brand_id); - END IF; - - INSERT INTO time_tracking_workers (brand_id, name, role, lang, pin, worker_number) - VALUES (p_brand_id, p_name, p_role, p_lang, v_pin, v_number) - RETURNING * INTO v_worker; - - RETURN json_build_object( - 'success', true, - 'worker', row_to_json(v_worker), - 'pin', v_pin, - 'worker_number', v_number - ); -END; -$$; - --- Add RLS policies for time tracking tables --- These are brand-scoped tables, so RLS enforces brand isolation - -ALTER TABLE time_tracking_workers ENABLE ROW LEVEL SECURITY; - --- Workers can only see their own record (for PIN verification) -CREATE POLICY "Workers can select own record" - ON time_tracking_workers FOR SELECT - USING (true); - --- Brand admins can manage workers for their brand -CREATE POLICY "Brand admins can manage workers" - ON time_tracking_workers FOR ALL - USING ( - brand_id IN ( - SELECT brand_id FROM admin_users - WHERE admin_users.id = current_setting('request.jwt.claim.sub', true)::uuid - AND admin_users.role = 'brand_admin' - ) - ); - -ALTER TABLE time_tracking_tasks ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Brand admins can manage tasks" - ON time_tracking_tasks FOR ALL - USING ( - brand_id IN ( - SELECT brand_id FROM admin_users - WHERE admin_users.id = current_setting('request.jwt.claim.sub', true)::uuid - AND admin_users.role = 'brand_admin' - ) - ); - -ALTER TABLE time_tracking_logs ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Brand admins can manage time logs" - ON time_tracking_logs FOR ALL - USING ( - EXISTS ( - SELECT 1 FROM time_tracking_workers w - JOIN admin_users a ON a.id = current_setting('request.jwt.claim.sub', true)::uuid - WHERE w.id = time_tracking_logs.worker_id - AND a.brand_id = w.brand_id - AND a.role = 'brand_admin' - ) - ); - --- Assign worker_number to existing workers (they already have PINs) -UPDATE time_tracking_workers -SET worker_number = assign_worker_number(brand_id) -WHERE worker_number IS NULL; - --- Set NOT NULL constraint -ALTER TABLE time_tracking_workers ALTER COLUMN worker_number SET NOT NULL; \ No newline at end of file diff --git a/supabase/migrations/.archived/145_create_product_images_bucket.sql b/supabase/migrations/.archived/145_create_product_images_bucket.sql deleted file mode 100644 index 56885df..0000000 --- a/supabase/migrations/.archived/145_create_product_images_bucket.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Create product-images bucket for product images (idempotent) -INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) -SELECT 'product-images', 'product-images', true, 5242880, ARRAY['image/png', 'image/jpeg', 'image/webp'] -WHERE NOT EXISTS ( - SELECT 1 FROM storage.buckets WHERE id = 'product-images' OR name = 'product-images' -); - --- Enable public access to product-images bucket (re-runnable) -DROP POLICY IF EXISTS "Public can view product-images" ON storage.objects; -CREATE POLICY "Public can view product-images" -ON storage.objects FOR SELECT -USING (bucket_id = 'product-images'); - -DROP POLICY IF EXISTS "Admins can upload product-images" ON storage.objects; -CREATE POLICY "Admins can upload product-images" -ON storage.objects FOR INSERT -WITH CHECK (bucket_id = 'product-images'); \ No newline at end of file diff --git a/supabase/migrations/.archived/146_sitemap_stops_rpc.sql b/supabase/migrations/.archived/146_sitemap_stops_rpc.sql deleted file mode 100644 index 965671c..0000000 --- a/supabase/migrations/.archived/146_sitemap_stops_rpc.sql +++ /dev/null @@ -1,32 +0,0 @@ --- Migration: 146_sitemap_stops_rpc.sql --- Description: RPC to fetch active stops with brand slug for sitemap generation - -CREATE OR REPLACE FUNCTION get_active_stops_with_brand() -RETURNS TABLE ( - slug TEXT, - brand_slug TEXT, - last_modified TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN QUERY - SELECT - s.slug, - b.slug AS brand_slug, - COALESCE(s.updated_at, s.created_at) AS last_modified - FROM stops s - INNER JOIN brands b ON s.brand_id = b.id - WHERE s.active = true - AND s.slug IS NOT NULL - AND b.slug IN ('tuxedo', 'indian-river-direct') - ORDER BY s.updated_at DESC; -END; -$$; - --- Grant execute to anon and service role -GRANT EXECUTE ON FUNCTION get_active_stops_with_brand() TO anon; -GRANT EXECUTE ON FUNCTION get_active_stops_with_brand() TO service_role; -GRANT EXECUTE ON FUNCTION get_active_stops_with_brand() TO authenticated; diff --git a/supabase/migrations/.archived/147_admin_create_stop_rpcs.sql b/supabase/migrations/.archived/147_admin_create_stop_rpcs.sql deleted file mode 100644 index d0510f3..0000000 --- a/supabase/migrations/.archived/147_admin_create_stop_rpcs.sql +++ /dev/null @@ -1,244 +0,0 @@ --- 147_admin_create_stop_rpcs.sql --- SECURITY DEFINER RPCs for stop creation. --- --- The stops table has RLS enabled with `block_stops_mutations` --- (FOR INSERT WITH CHECK (false)) — direct REST inserts from --- server actions are blocked. Previously `createStop` / --- `createStopsBatch` in src/actions/stops/ relied on --- SUPABASE_SERVICE_ROLE_KEY to bypass RLS, but: --- 1. In production environments the env var may be unset, --- which silently downgrades the apikey header to anon and --- triggers 42501. --- 2. createStopsBatch was hardcoded to NEXT_PUBLIC_SUPABASE_ANON_KEY, --- which always fails with RLS enabled. --- --- These RPCs run as the function owner (bypasses RLS) and accept --- p_brand_id for explicit brand scoping. The application layer --- (server actions) still validates the caller has can_manage_stops --- via getAdminUser() before invoking the RPC. --- --- Table schema (relevant columns): --- id UUID PK --- brand_id UUID FK brands --- city TEXT --- state TEXT --- location TEXT --- date TIMESTAMPTZ --- time TEXT -- free-form ("8:00 AM – 2:00 PM") --- address TEXT --- zip TEXT --- cutoff_time TIMESTAMPTZ -- accepts ISO datetime OR "HH:MM" --- slug TEXT --- active BOOLEAN --- status TEXT -- 'draft' | 'active' | ... - -BEGIN; - --- ── 1. admin_create_stop ─────────────────────────────────────────────────── --- Inserts a single stop. Slug is derived from city + date; if a stop with --- the same slug already exists for the brand, a numeric suffix is appended. --- --- Robust type handling: --- * p_date TEXT -> cast to TIMESTAMPTZ; NULLIF handles empty string --- * p_cutoff_time TEXT -> accepts full ISO datetime ("2025-01-01T08:00") --- OR a time-only value ("08:00" / "08:00:00") which is combined with --- p_date to form a TIMESTAMPTZ. NULLIF handles empty string. --- * p_time TEXT is passed through as-is (the column is free-form text). - -CREATE OR REPLACE FUNCTION public.admin_create_stop( - p_brand_id UUID, - p_city TEXT, - p_state TEXT, - p_location TEXT, - p_date TEXT, - p_time TEXT, - p_address TEXT DEFAULT NULL, - p_zip TEXT DEFAULT NULL, - p_cutoff_time TEXT DEFAULT NULL, - p_active BOOLEAN DEFAULT false -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_date_value TIMESTAMPTZ; - v_cutoff_val TIMESTAMPTZ; - v_slug TEXT; - v_slug_base TEXT; - v_id UUID; - v_counter INT := 0; -BEGIN - IF p_brand_id IS NULL THEN - RAISE EXCEPTION 'brand_id is required'; - END IF; - IF p_city IS NULL OR length(trim(p_city)) = 0 THEN - RAISE EXCEPTION 'city is required'; - END IF; - - -- ── Parse p_date ───────────────────────────────────────────────────────── - v_date_value := NULLIF(trim(p_date), '')::TIMESTAMPTZ; - IF v_date_value IS NULL THEN - RAISE EXCEPTION 'date is required'; - END IF; - - -- ── Parse p_cutoff_time (accepts ISO datetime OR "HH:MM[:SS]") ─────────── - IF p_cutoff_time IS NULL OR length(trim(p_cutoff_time)) = 0 THEN - v_cutoff_val := NULL; - ELSIF p_cutoff_time ~ '^\d{1,2}:\d{2}(:\d{2})?$' THEN - -- Time-only value — combine with the stop's date - v_cutoff_val := (v_date_value::DATE + trim(p_cutoff_time)::TIME)::TIMESTAMPTZ; - ELSE - -- Assume a full ISO datetime string - v_cutoff_val := trim(p_cutoff_time)::TIMESTAMPTZ; - END IF; - - -- ── Derive unique slug ─────────────────────────────────────────────────── - v_slug_base := lower(regexp_replace(trim(p_city), '\s+', '-', 'g')) - || '-' || to_char(v_date_value, 'YYYY-MM-DD'); - v_slug := v_slug_base; - - WHILE EXISTS (SELECT 1 FROM stops WHERE brand_id = p_brand_id AND slug = v_slug) LOOP - v_counter := v_counter + 1; - v_slug := v_slug_base || '-' || v_counter; - END LOOP; - - -- ── Insert ─────────────────────────────────────────────────────────────── - INSERT INTO stops ( - brand_id, city, state, location, date, time, slug, - address, zip, cutoff_time, active, status - ) VALUES ( - p_brand_id, p_city, p_state, p_location, - v_date_value, p_time, v_slug, - p_address, p_zip, v_cutoff_val, p_active, 'draft' - ) - RETURNING id INTO v_id; - - RETURN jsonb_build_object('id', v_id, 'slug', v_slug); -EXCEPTION WHEN OTHERS THEN - -- Re-raise as a structured error so the client gets a useful message - RAISE EXCEPTION 'admin_create_stop failed: % (SQLSTATE %)', SQLERRM, SQLSTATE; -END; -$$; - --- ── 2. admin_create_stops_batch ──────────────────────────────────────────── --- Inserts many stops in one call. Returns JSONB with created IDs + slugs. --- On any per-row failure the whole batch rolls back (transactional). --- Same robust date / cutoff_time handling as the single-row RPC. - -CREATE OR REPLACE FUNCTION public.admin_create_stops_batch( - p_brand_id UUID, - p_stops JSONB -- array of {city, state, location, date, time, address?, zip?, cutoff_time?, active?} -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_row JSONB; - v_result JSONB := '[]'::JSONB; - v_slug_base TEXT; - v_slug TEXT; - v_counter INT; - v_id UUID; - v_city TEXT; - v_state TEXT; - v_location TEXT; - v_date_text TEXT; - v_time TEXT; - v_address TEXT; - v_zip TEXT; - v_cutoff_txt TEXT; - v_active BOOLEAN; - v_date_value TIMESTAMPTZ; - v_cutoff_val TIMESTAMPTZ; -BEGIN - IF p_brand_id IS NULL THEN - RAISE EXCEPTION 'brand_id is required'; - END IF; - - IF p_stops IS NULL OR jsonb_typeof(p_stops) <> 'array' OR jsonb_array_length(p_stops) = 0 THEN - RETURN '[]'::JSONB; - END IF; - - FOR v_row IN SELECT * FROM jsonb_array_elements(p_stops) LOOP - v_city := v_row->>'city'; - v_state := v_row->>'state'; - v_location := v_row->>'location'; - v_date_text := v_row->>'date'; - v_time := v_row->>'time'; - v_address := v_row->>'address'; - v_zip := v_row->>'zip'; - v_cutoff_txt := v_row->>'cutoff_time'; - v_active := COALESCE((v_row->>'active')::BOOLEAN, false); - - IF v_city IS NULL OR length(trim(v_city)) = 0 THEN - RAISE EXCEPTION 'city is required for all stops'; - END IF; - - -- Parse date - v_date_value := NULLIF(trim(v_date_text), '')::TIMESTAMPTZ; - IF v_date_value IS NULL THEN - RAISE EXCEPTION 'date is required for all stops'; - END IF; - - -- Parse cutoff_time (accepts ISO datetime OR "HH:MM[:SS]") - IF v_cutoff_txt IS NULL OR length(trim(v_cutoff_txt)) = 0 THEN - v_cutoff_val := NULL; - ELSIF v_cutoff_txt ~ '^\d{1,2}:\d{2}(:\d{2})?$' THEN - v_cutoff_val := (v_date_value::DATE + trim(v_cutoff_txt)::TIME)::TIMESTAMPTZ; - ELSE - v_cutoff_val := trim(v_cutoff_txt)::TIMESTAMPTZ; - END IF; - - v_slug_base := lower(regexp_replace(trim(v_city), '\s+', '-', 'g')) - || '-' || to_char(v_date_value, 'YYYY-MM-DD'); - v_slug := v_slug_base; - v_counter := 0; - WHILE EXISTS (SELECT 1 FROM stops WHERE brand_id = p_brand_id AND slug = v_slug) LOOP - v_counter := v_counter + 1; - v_slug := v_slug_base || '-' || v_counter; - END LOOP; - - INSERT INTO stops ( - brand_id, city, state, location, date, time, slug, - address, zip, cutoff_time, active, status - ) VALUES ( - p_brand_id, v_city, v_state, v_location, - v_date_value, v_time, v_slug, - v_address, v_zip, v_cutoff_val, v_active, 'draft' - ) - RETURNING id INTO v_id; - - v_result := v_result || jsonb_build_object('id', v_id, 'slug', v_slug)::JSONB; - END LOOP; - - RETURN v_result; -EXCEPTION WHEN OTHERS THEN - RAISE EXCEPTION 'admin_create_stops_batch failed: % (SQLSTATE %)', SQLERRM, SQLSTATE; -END; -$$; - --- ── 3. Grants ────────────────────────────────────────────────────────────── --- SECURITY DEFINER runs as the function owner (postgres), so RLS is bypassed. --- But PostgREST still needs explicit EXECUTE grants to expose the RPC to anon --- (used by dev / unauthenticated public flows) and authenticated / service_role. - -GRANT EXECUTE ON FUNCTION public.admin_create_stop( - UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN -) TO anon, authenticated, service_role; - -GRANT EXECUTE ON FUNCTION public.admin_create_stops_batch(UUID, JSONB) - TO anon, authenticated, service_role; - --- ── 4. Reload PostgREST schema cache ─────────────────────────────────────── --- Without this, PostgREST will keep using its cached function list and return --- PGRST202 ("function not found in schema") until the cache expires naturally. --- Other migrations in this repo do this; 147 was missing it and was the --- most common cause of PGRST202 right after applying the migration. - -NOTIFY pgrst, 'reload schema'; - -COMMIT; diff --git a/supabase/migrations/.archived/148_public_stops_rpc.sql b/supabase/migrations/.archived/148_public_stops_rpc.sql deleted file mode 100644 index 8a769ce..0000000 --- a/supabase/migrations/.archived/148_public_stops_rpc.sql +++ /dev/null @@ -1,49 +0,0 @@ --- Migration: 148_public_stops_rpc.sql --- Description: Public RPC to fetch active stops for a brand by slug. --- Used by the /tuxedo and /indian-river-direct storefront stop pages --- (refactored from client-side supabase.from() to server components). --- --- SECURITY DEFINER bypasses RLS for a clean public read; the function --- filters by brand slug + active=true so callers can't enumerate other --- brands' stops. Granted to anon for public storefront access. - -CREATE OR REPLACE FUNCTION get_public_stops_for_brand(p_brand_slug TEXT) -RETURNS TABLE ( - id UUID, - city TEXT, - state TEXT, - date TIMESTAMPTZ, - "time" TEXT, - location TEXT, - address TEXT, - slug TEXT, - cutoff_time TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN QUERY - SELECT - s.id, - s.city, - s.state, - s.date, - s."time", - s.location, - s.address, - s.slug, - s.cutoff_time - FROM stops s - INNER JOIN brands b ON s.brand_id = b.id - WHERE s.active = true - AND b.slug = p_brand_slug - ORDER BY s.date ASC; -END; -$$; - --- Public storefront pages call this via the anon key. -GRANT EXECUTE ON FUNCTION get_public_stops_for_brand(TEXT) TO anon; -GRANT EXECUTE ON FUNCTION get_public_stops_for_brand(TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION get_public_stops_for_brand(TEXT) TO service_role; diff --git a/supabase/migrations/.archived/149_product_availability_dates.sql b/supabase/migrations/.archived/149_product_availability_dates.sql deleted file mode 100644 index 5948590..0000000 --- a/supabase/migrations/.archived/149_product_availability_dates.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Add seasonal availability date fields to products table --- Allows products to have start/end availability dates (e.g., "Available mid-July through mid-September") - -ALTER TABLE products ADD COLUMN IF NOT EXISTS available_from TIMESTAMPTZ; -ALTER TABLE products ADD COLUMN IF NOT EXISTS available_until TIMESTAMPTZ; - --- Add comment for documentation -COMMENT ON COLUMN products.available_from IS 'Start date for product availability (seasonal)'; -COMMENT ON COLUMN products.available_until IS 'End date for product availability (seasonal)'; \ No newline at end of file diff --git a/supabase/migrations/.archived/200_production_features.sql b/supabase/migrations/.archived/200_production_features.sql deleted file mode 100644 index 7196ede..0000000 --- a/supabase/migrations/.archived/200_production_features.sql +++ /dev/null @@ -1,258 +0,0 @@ --- Production Database Migrations --- Add comprehensive RLS policies, audit tables, referral system, and seed data - -BEGIN; - --- ============================================================================= --- REFERRAL SYSTEM --- ============================================================================= - --- Referral codes table -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, - referral_code VARCHAR(50) UNIQUE NOT NULL, - referrer_email VARCHAR(255) NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - expires_at TIMESTAMPTZ DEFAULT (NOW() + INTERVAL '1 year'), - max_uses INTEGER DEFAULT 1, - current_uses INTEGER DEFAULT 0, - is_active BOOLEAN DEFAULT true, - reward_type VARCHAR(50) DEFAULT 'percentage', - reward_value DECIMAL(10,2) DEFAULT 20.00, - metadata JSONB DEFAULT '{}'::jsonb -); - --- Referral redemptions table -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, - referred_user_id UUID NOT NULL, - referred_email VARCHAR(255) NOT NULL, - redeemed_at TIMESTAMPTZ DEFAULT NOW(), - reward_type VARCHAR(50), - reward_value DECIMAL(10,2), - is_credit_applied BOOLEAN DEFAULT false, - signup_plan VARCHAR(50), - signup_value DECIMAL(10,2), - metadata JSONB DEFAULT '{}'::jsonb -); - --- ============================================================================= --- CHANGELOG SYSTEM --- ============================================================================= - -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, - title VARCHAR(255) NOT NULL, - description TEXT, - content JSONB NOT NULL DEFAULT '[]'::jsonb, - released_at TIMESTAMPTZ DEFAULT NOW(), - is_published BOOLEAN DEFAULT false, - feature_type VARCHAR(50) DEFAULT 'general', - metadata JSONB DEFAULT '{}'::jsonb, - UNIQUE(brand_id, version) -); - -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, - read_at TIMESTAMPTZ DEFAULT NOW(), - dismissed BOOLEAN DEFAULT false, - UNIQUE(user_id, changelog_id) -); - --- ============================================================================= --- 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, - current_step VARCHAR(50) NOT NULL, - completed_steps JSONB DEFAULT '[]'::jsonb, - skipped_steps JSONB DEFAULT '[]'::jsonb, - data JSONB DEFAULT '{}'::jsonb, - started_at TIMESTAMPTZ DEFAULT NOW(), - completed_at TIMESTAMPTZ, - is_completed BOOLEAN DEFAULT false, - metadata JSONB DEFAULT '{}'::jsonb, - UNIQUE(brand_id, user_id) -); - --- ============================================================================= --- USER ACTIVITY / ANALYTICS --- ============================================================================= - -CREATE TABLE IF NOT EXISTS user_activity_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL, - brand_id UUID REFERENCES brands(id) ON DELETE SET NULL, - action VARCHAR(100) NOT NULL, - resource_type VARCHAR(50), - resource_id UUID, - ip_address INET, - user_agent TEXT, - metadata JSONB DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ DEFAULT NOW() -); - --- If table pre-existed from 036 (without brand_id etc), add the columns now so policies below succeed -ALTER TABLE user_activity_logs - ADD COLUMN IF NOT EXISTS brand_id UUID REFERENCES brands(id) ON DELETE SET NULL, - ADD COLUMN IF NOT EXISTS action VARCHAR(100), - ADD COLUMN IF NOT EXISTS resource_type VARCHAR(50), - ADD COLUMN IF NOT EXISTS resource_id UUID, - ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb; - --- ============================================================================= --- API KEYS FOR EXTERNAL INTEGRATIONS --- ============================================================================= - -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, - key_hash VARCHAR(255) NOT NULL, - key_prefix VARCHAR(20), - permissions JSONB DEFAULT '["read"]'::jsonb, - rate_limit INTEGER DEFAULT 1000, - last_used_at TIMESTAMPTZ, - expires_at TIMESTAMPTZ, - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMPTZ DEFAULT NOW(), - created_by UUID NOT NULL -); - --- ============================================================================= --- 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, - email_orders BOOLEAN DEFAULT true, - email_marketing BOOLEAN DEFAULT false, - email_reports BOOLEAN DEFAULT true, - email_billing BOOLEAN DEFAULT true, - sms_orders BOOLEAN DEFAULT false, - sms_marketing BOOLEAN DEFAULT false, - push_orders BOOLEAN DEFAULT true, - push_marketing BOOLEAN DEFAULT false, - quiet_hours_start TIME, - quiet_hours_end TIME, - timezone VARCHAR(50) DEFAULT 'America/New_York', - metadata JSONB DEFAULT '{}'::jsonb, - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(user_id, brand_id) -); - --- ============================================================================= --- REFINED RLS POLICIES --- ============================================================================= - --- Enable RLS on new tables -ALTER TABLE referral_codes ENABLE ROW LEVEL SECURITY; -ALTER TABLE referral_redemptions ENABLE ROW LEVEL SECURITY; -ALTER TABLE changelogs ENABLE ROW LEVEL SECURITY; -ALTER TABLE changelog_reads ENABLE ROW LEVEL SECURITY; -ALTER TABLE onboarding_progress ENABLE ROW LEVEL SECURITY; -ALTER TABLE user_activity_logs ENABLE ROW LEVEL SECURITY; -ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; -ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY; - --- Referral codes policies -CREATE POLICY "Users can view their own referral codes" - ON referral_codes FOR SELECT - USING (referrer_user_id = current_setting('app.current_user_id', true)::uuid); - -CREATE POLICY "Platform admins can manage all referral codes" - ON referral_codes FOR ALL - USING (current_setting('app.current_user_role', true) = 'platform_admin'); - -CREATE POLICY "Brand admins can manage their brand's referral codes" - ON referral_codes FOR ALL - USING (brand_id = current_setting('app.current_brand_id', true)::uuid); - --- Changelogs policies - public read for all authenticated -CREATE POLICY "Authenticated users can view published changelogs" - ON changelogs FOR SELECT - USING ( - is_published = true - AND ( - current_setting('app.current_user_role', true) = 'platform_admin' - OR brand_id = current_setting('app.current_brand_id', true)::uuid - ) - ); - -CREATE POLICY "Brand admins can manage their changelogs" - ON changelogs FOR ALL - USING (brand_id = current_setting('app.current_brand_id', true)::uuid); - --- Onboarding progress policies -CREATE POLICY "Users can view their own onboarding progress" - ON onboarding_progress FOR SELECT - USING (user_id = current_setting('app.current_user_id', true)::uuid); - -CREATE POLICY "Users can update their own onboarding progress" - ON onboarding_progress FOR UPDATE - USING (user_id = current_setting('app.current_user_id', true)::uuid); - -CREATE POLICY "Brand admins can view onboarding progress for their brand" - ON onboarding_progress FOR SELECT - USING (brand_id = current_setting('app.current_brand_id', true)::uuid); - --- User activity logs - admins only -CREATE POLICY "Brand admins can view activity logs for their brand" - ON user_activity_logs FOR SELECT - USING ( - brand_id = current_setting('app.current_brand_id', true)::uuid - OR current_setting('app.current_user_role', true) = 'platform_admin' - ); - --- API keys policies -CREATE POLICY "Brand admins can manage their API keys" - ON api_keys FOR ALL - USING (brand_id = current_setting('app.current_brand_id', true)::uuid); - -CREATE POLICY "Anyone can read active API key prefix (for verification)" - ON api_keys FOR SELECT - USING (is_active = true); - --- Notification preferences policies -CREATE POLICY "Users can manage their own notification preferences" - ON notification_preferences FOR ALL - USING (user_id = current_setting('app.current_user_id', true)::uuid); - -CREATE POLICY "Brand admins can view preferences for their brand users" - ON notification_preferences FOR SELECT - USING (brand_id = current_setting('app.current_brand_id', true)::uuid); - --- ============================================================================= --- COMPREHENSIVE INDEXES --- ============================================================================= - -CREATE INDEX IF NOT EXISTS idx_referral_codes_brand ON referral_codes(brand_id); -CREATE INDEX IF NOT EXISTS idx_referral_codes_referrer ON referral_codes(referrer_user_id); -CREATE INDEX IF NOT EXISTS idx_referral_codes_code ON referral_codes(referral_code); -CREATE INDEX IF NOT EXISTS idx_referral_redemptions_referrer ON referral_redemptions(referral_code_id); -CREATE INDEX IF NOT EXISTS idx_referral_redemptions_referred ON referral_redemptions(referred_user_id); -CREATE INDEX IF NOT EXISTS idx_changelogs_brand ON changelogs(brand_id); -CREATE INDEX IF NOT EXISTS idx_changelogs_published ON changelogs(is_published, released_at DESC); -CREATE INDEX IF NOT EXISTS idx_changelog_reads_user ON changelog_reads(user_id, changelog_id); -CREATE INDEX IF NOT EXISTS idx_onboarding_brand ON onboarding_progress(brand_id); -CREATE INDEX IF NOT EXISTS idx_onboarding_user ON onboarding_progress(user_id); -CREATE INDEX IF NOT EXISTS idx_user_activity_user ON user_activity_logs(user_id); -CREATE INDEX IF NOT EXISTS idx_user_activity_brand ON user_activity_logs(brand_id); -CREATE INDEX IF NOT EXISTS idx_user_activity_created ON user_activity_logs(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_api_keys_brand ON api_keys(brand_id); -CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active); - -COMMIT; \ No newline at end of file diff --git a/supabase/migrations/.archived/201_seed_data.sql b/supabase/migrations/.archived/201_seed_data.sql deleted file mode 100644 index 86519e4..0000000 --- a/supabase/migrations/.archived/201_seed_data.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Seed data for production development and testing - -BEGIN; - --- ============================================================================= --- DEMO BRANDS --- ============================================================================= - -INSERT INTO brands (id, name, slug, plan_tier, max_users, max_stops_monthly, max_products, stripe_customer_id, created_at) -VALUES - ('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Sunrise Farms', 'sunrise-farms', 'enterprise', 10, 100, 500, 'cus_demo_sunrise', NOW()), - ('b2c3d4e5-f6a7-8901-bcde-f12345678901', 'Green Valley Organics', 'green-valley', 'farm', 5, 50, 250, 'cus_demo_green', NOW()), - ('c3d4e5f6-a7b8-9012-cdef-123456789012', 'Orchard Fresh', 'orchard-fresh', 'starter', 2, 10, 50, 'cus_demo_orchard', NOW()) -ON CONFLICT (id) DO NOTHING; - - --- NOTE: The remainder of the original seed data (products, stops, orders, water logs, etc.) --- has been removed from this migration because the column layouts in the live DB --- (shaped by many prior migrations) no longer match the INSERTs here. --- The 3 demo brands above will be inserted (now that plan_tier/max_* columns exist). --- Use the admin UI or separate scripts to populate test data as needed. - -COMMIT; diff --git a/supabase/migrations/.archived/202_delete_stop_orders_guard_fix.sql b/supabase/migrations/.archived/202_delete_stop_orders_guard_fix.sql deleted file mode 100644 index 1c7f2d7..0000000 --- a/supabase/migrations/.archived/202_delete_stop_orders_guard_fix.sql +++ /dev/null @@ -1,70 +0,0 @@ --- Migration 202: Fix delete_stop RPC — orders table has no deleted_at column --- --- The delete_stop RPC added in migration 075 guards against deleting a stop --- that has active orders: --- --- SELECT COUNT(*) INTO v_active_order_count --- FROM orders o JOIN stops s ON o.stop_id = s.id --- WHERE s.id = p_stop_id --- AND o.pickup_complete = false --- AND o.deleted_at IS NULL; -- <-- column does not exist on orders --- --- The orders table never got a deleted_at column, so the SELECT raises --- "column o.deleted_at does not exist" and the RPC returns HTTP 400 from --- PostgREST. Stop deletion has been silently broken since 075 landed. --- --- Fix: drop the o.deleted_at IS NULL clause. The pickup_complete = false --- guard is sufficient for now (no soft-deleted orders can exist). --- If/when orders get a deleted_at column, re-add the clause. --- --- This migration only touches the function. Schema is unchanged. - -CREATE OR REPLACE FUNCTION public.delete_stop(p_stop_id UUID, p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop RECORD; - v_active_order_count INT; -BEGIN - -- Lock the stop row for update - SELECT * INTO v_stop FROM stops WHERE id = p_stop_id FOR UPDATE; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Brand scoping: brand_admin can only delete their own stops - IF p_brand_id IS NOT NULL AND v_stop.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - ELSIF p_brand_id IS NULL AND v_stop.brand_id IS NOT NULL THEN - -- Caller didn't pass brand_id but stop has one — guard against platform admin accident - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Guard: refuse to delete a stop that still has active (un-picked-up) orders. - -- Note: orders has no deleted_at column (hard delete pattern) so we only - -- check pickup_complete. Re-add `AND o.deleted_at IS NULL` if/when orders - -- grows a soft-delete column. - SELECT COUNT(*) INTO v_active_order_count - FROM orders o - JOIN stops s ON o.stop_id = s.id - WHERE s.id = p_stop_id - AND o.pickup_complete = false; - - IF v_active_order_count > 0 THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Cannot delete stop — it has ' || v_active_order_count || ' active order(s). Mark orders as picked up first.' - ); - END IF; - - -- Soft delete - UPDATE stops SET deleted_at = NOW() WHERE id = p_stop_id; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- Grants are inherited from 075; verify they're present (no-op if so). -GRANT EXECUTE ON FUNCTION public.delete_stop(UUID, UUID) TO anon, authenticated, service_role; diff --git a/supabase/migrations/.archived/202_fix_admin_create_stop.sql b/supabase/migrations/.archived/202_fix_admin_create_stop.sql deleted file mode 100644 index db81bd7..0000000 --- a/supabase/migrations/.archived/202_fix_admin_create_stop.sql +++ /dev/null @@ -1,145 +0,0 @@ --- 202_fix_admin_create_stop.sql --- Fix/recreate admin_create_stop RPC using the signature required by callers. --- --- The previous migration 147 defined the function but was never applied (or --- was lost) to the live DB, resulting in PGRST202 "function not found" when --- "Add New Stop" (createStop server action) tries to call it. --- --- This uses the parameter order from the reported error + best-practice --- style (always return jsonb with success/stop_id or error; no RAISE). --- Robust date/cutoff parsing and slug generation are preserved so that --- required columns (slug, status) are populated and duplicates are avoided. --- --- Matches the client call in src/actions/stops/create-stop.ts which passes: --- p_brand_id, p_city, p_state, p_location, p_date, p_time, --- p_address, p_zip, p_cutoff_time, p_active --- (named keys; param order in CREATE only affects the GRANT signature). - -BEGIN; - --- Drop any stale overloads that might confuse PostgREST cache or signature matching. -DO $$ -BEGIN - DROP FUNCTION IF EXISTS public.admin_create_stop(UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN); - DROP FUNCTION IF EXISTS public.admin_create_stop(BOOLEAN, TEXT, UUID, TEXT, TIME, DATE, TEXT, TEXT, TIME, TEXT); - DROP FUNCTION IF EXISTS public.admin_create_stop(BOOLEAN, TEXT, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT); -EXCEPTION WHEN OTHERS THEN - -- ignore if not present - NULL; -END $$; - --- ── admin_create_stop (single) ────────────────────────────────────────────── -CREATE OR REPLACE FUNCTION public.admin_create_stop( - p_active boolean, - p_address text, - p_brand_id uuid, - p_city text, - p_cutoff_time text, - p_date text, - p_location text, - p_state text, - p_time text, - p_zip text -) -RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_date_value TIMESTAMPTZ; - v_cutoff_val TIMESTAMPTZ; - v_slug TEXT; - v_slug_base TEXT; - v_id UUID; - v_counter INT := 0; -BEGIN - IF p_brand_id IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'brand_id is required'); - END IF; - IF p_city IS NULL OR length(trim(p_city)) = 0 THEN - RETURN jsonb_build_object('success', false, 'error', 'city is required'); - END IF; - - -- ── Parse p_date (TEXT -> TIMESTAMPTZ) ──────────────────────────────────── - v_date_value := NULLIF(trim(p_date), '')::TIMESTAMPTZ; - IF v_date_value IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'date is required'); - END IF; - - -- ── Parse p_cutoff_time (accepts ISO datetime OR "HH:MM[:SS]") ──────────── - IF p_cutoff_time IS NULL OR length(trim(p_cutoff_time)) = 0 THEN - v_cutoff_val := NULL; - ELSIF p_cutoff_time ~ '^\d{1,2}:\d{2}(:\d{2})?$' THEN - -- Time-only value — combine with the stop's date - v_cutoff_val := (v_date_value::DATE + trim(p_cutoff_time)::TIME)::TIMESTAMPTZ; - ELSE - -- Assume a full ISO datetime string - v_cutoff_val := trim(p_cutoff_time)::TIMESTAMPTZ; - END IF; - - -- ── Derive unique slug (required column) ────────────────────────────────── - v_slug_base := lower(regexp_replace(trim(p_city), '\s+', '-', 'g')) - || '-' || to_char(v_date_value, 'YYYY-MM-DD'); - v_slug := v_slug_base; - - WHILE EXISTS (SELECT 1 FROM stops WHERE brand_id = p_brand_id AND slug = v_slug) LOOP - v_counter := v_counter + 1; - v_slug := v_slug_base || '-' || v_counter; - END LOOP; - - -- ── Insert (include all required columns + status) ──────────────────────── - INSERT INTO stops ( - active, - address, - brand_id, - city, - cutoff_time, - date, - location, - state, - time, - zip, - slug, - status - ) - VALUES ( - p_active, - p_address, - p_brand_id, - p_city, - v_cutoff_val, - v_date_value, - p_location, - p_state, - p_time, - p_zip, - v_slug, - 'draft' - ) - RETURNING id INTO v_id; - - RETURN jsonb_build_object( - 'success', true, - 'stop_id', v_id, - 'message', 'Stop created successfully' - ); -EXCEPTION WHEN OTHERS THEN - RETURN jsonb_build_object( - 'success', false, - 'error', SQLERRM - ); -END; -$$; - --- Grant for the exact signature (types in declaration order). --- SECURITY DEFINER bypasses RLS; anon is used intentionally from server action. -GRANT EXECUTE ON FUNCTION public.admin_create_stop( - boolean, text, uuid, text, text, text, text, text, text, text -) TO anon, authenticated, service_role; - --- Reload PostgREST schema cache immediately so the function appears without --- waiting for the periodic refresh (common source of PGRST202 after create). -NOTIFY pgrst, 'reload schema'; - -COMMIT; diff --git a/supabase/migrations/.archived/203_locations_table.sql b/supabase/migrations/.archived/203_locations_table.sql deleted file mode 100644 index ed16106..0000000 --- a/supabase/migrations/.archived/203_locations_table.sql +++ /dev/null @@ -1,470 +0,0 @@ --- Migration 203: Locations table (reusable venues) + admin RPCs --- --- Purpose --- ------- --- Today, "where a stop happens" is captured in 3 denormalized fields on the --- stops table: --- * location (venue name, e.g. "Tractor Supply") --- * address (street address, e.g. "13778 E I-25 Frontage Rd") --- * phone (was never on stops — only enriched from the xlsx Stop Directory) --- --- That works for one-off inserts but breaks down for the Tuxedo 2026 tour --- where the same venue (Tractor Supply, Murdoch's, etc.) repeats 5-15 times --- across the season with the same address/phone/contact. Editing the address --- means updating N stops. Listing "all unique venues we visit" requires --- SELECT DISTINCT with a GROUP BY, not a real table. --- --- This migration introduces a `locations` table — the canonical venue record --- — and links each stop to it via a nullable location_id FK. Existing --- denormalized fields on stops are kept (backwards compatible) but new --- admin paths should prefer location_id. --- --- The 269 seeded Tuxedo stops are backfilled into 49 unique locations --- (one per (brand_id, name, address) tuple) by a follow-up script — --- this migration only creates the schema and RPCs. --- --- RPCs (all SECURITY DEFINER, all brand-scoped): --- * admin_create_location — insert single location --- * admin_create_locations_batch — insert many in one call (mirrors stops pattern) --- * admin_update_location — partial update of name/address/phone/contact --- * admin_delete_location — soft delete via deleted_at (mirrors stops) --- * get_locations_for_brand — public read RPC (filters deleted_at, soft-publish) --- * admin_attach_location_to_stop — link a stop to a location (idempotent) - -BEGIN; - --- ============================================================================= --- 1. locations table --- ============================================================================= - -CREATE TABLE IF NOT EXISTS public.locations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - brand_id UUID NOT NULL REFERENCES public.brands(id) ON DELETE CASCADE, - name TEXT NOT NULL, - address TEXT, - city TEXT, - state TEXT, - zip TEXT, - phone TEXT, - contact_name TEXT, - contact_email TEXT, - notes TEXT, - active BOOLEAN NOT NULL DEFAULT true, - deleted_at TIMESTAMPTZ DEFAULT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Slug per brand for nice URLs (locations/slug-{id}) — derived from name + zip --- to disambiguate. Same collision-handling pattern as stops. -ALTER TABLE public.locations - ADD COLUMN IF NOT EXISTS slug TEXT; - -CREATE UNIQUE INDEX IF NOT EXISTS idx_locations_brand_slug - ON public.locations (brand_id, slug) - WHERE deleted_at IS NULL; - -CREATE INDEX IF NOT EXISTS idx_locations_brand_active - ON public.locations (brand_id, active) - WHERE deleted_at IS NULL; - -CREATE INDEX IF NOT EXISTS idx_locations_brand_name - ON public.locations (brand_id, lower(name)) - WHERE deleted_at IS NULL; - --- updated_at trigger (reuses the generic set_updated_at fn if present) -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'set_updated_at') THEN - DROP TRIGGER IF EXISTS trg_locations_updated_at ON public.locations; - CREATE TRIGGER trg_locations_updated_at - BEFORE UPDATE ON public.locations - FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); - END IF; -END$$; - --- ============================================================================= --- 2. Link stops → locations --- ============================================================================= - -ALTER TABLE public.stops - ADD COLUMN IF NOT EXISTS location_id UUID REFERENCES public.locations(id) ON DELETE SET NULL; - -CREATE INDEX IF NOT EXISTS idx_stops_location_id - ON public.stops (location_id) - WHERE deleted_at IS NULL; - --- ============================================================================= --- 3. RLS for locations --- ============================================================================= - -ALTER TABLE public.locations ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "platform_admin_read_locations" ON public.locations; -DROP POLICY IF EXISTS "brand_admin_read_locations" ON public.locations; -DROP POLICY IF EXISTS "platform_admin_all_locations" ON public.locations; -DROP POLICY IF EXISTS "brand_admin_all_locations" ON public.locations; - --- Platform admin sees all non-deleted -CREATE POLICY "platform_admin_read_locations" ON public.locations - FOR SELECT USING ( - deleted_at IS NULL - AND EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - ) - ); - --- Brand admin / store_employee sees their own brand's non-deleted -CREATE POLICY "brand_admin_read_locations" ON public.locations - FOR SELECT USING ( - deleted_at IS NULL - AND EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role IN ('brand_admin', 'store_employee') - AND au.brand_id = locations.brand_id - ) - ); - --- Writes go through SECURITY DEFINER RPCs (no direct INSERT/UPDATE/DELETE from anon) --- so no INSERT/UPDATE/DELETE policies are granted to anon/authenticated. - --- ============================================================================= --- 4. RPCs --- ============================================================================= - --- ── 4a. admin_create_location ──────────────────────────────────────────────── -CREATE OR REPLACE FUNCTION public.admin_create_location( - p_brand_id UUID, - p_name TEXT, - p_address TEXT DEFAULT NULL, - p_city TEXT DEFAULT NULL, - p_state TEXT DEFAULT NULL, - p_zip TEXT DEFAULT NULL, - p_phone TEXT DEFAULT NULL, - p_contact_name TEXT DEFAULT NULL, - p_contact_email TEXT DEFAULT NULL, - p_notes TEXT DEFAULT NULL, - p_active BOOLEAN DEFAULT true -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_id UUID; - v_slug TEXT; - v_slug_base TEXT; - v_counter INT := 0; -BEGIN - IF p_brand_id IS NULL THEN - RAISE EXCEPTION 'brand_id is required'; - END IF; - IF p_name IS NULL OR length(trim(p_name)) = 0 THEN - RAISE EXCEPTION 'name is required'; - END IF; - - v_slug_base := lower(regexp_replace(trim(p_name), '[^a-z0-9]+', '-', 'g')) - || COALESCE('-' || regexp_replace(COALESCE(p_zip, ''), '[^a-z0-9]+', '', 'gi'), ''); - v_slug_base := trim(BOTH '-' FROM v_slug_base); - v_slug := v_slug_base; - WHILE EXISTS (SELECT 1 FROM locations WHERE brand_id = p_brand_id AND slug = v_slug AND deleted_at IS NULL) LOOP - v_counter := v_counter + 1; - v_slug := v_slug_base || '-' || v_counter; - END LOOP; - - INSERT INTO public.locations ( - brand_id, name, address, city, state, zip, phone, - contact_name, contact_email, notes, active, slug - ) VALUES ( - p_brand_id, trim(p_name), - NULLIF(trim(p_address), ''), - NULLIF(trim(p_city), ''), - NULLIF(trim(p_state), ''), - NULLIF(trim(p_zip), ''), - NULLIF(trim(p_phone), ''), - NULLIF(trim(p_contact_name), ''), - NULLIF(trim(p_contact_email), ''), - NULLIF(trim(p_notes), ''), - p_active, v_slug - ) - RETURNING id INTO v_id; - - RETURN jsonb_build_object('id', v_id, 'slug', v_slug); -EXCEPTION WHEN OTHERS THEN - RAISE EXCEPTION 'admin_create_location failed: % (SQLSTATE %)', SQLERRM, SQLSTATE; -END; -$$; - --- ── 4b. admin_create_locations_batch ───────────────────────────────────────── --- Mirrors admin_create_stops_batch. p_locations: JSONB array of objects with --- the same shape as the single-row params. Returns JSONB array of {id, slug}. --- Transactional: any per-row failure rolls the whole batch back. -CREATE OR REPLACE FUNCTION public.admin_create_locations_batch( - p_brand_id UUID, - p_locations JSONB -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_row JSONB; - v_result JSONB := '[]'::JSONB; - v_id UUID; - v_slug TEXT; - v_slug_base TEXT; - v_counter INT; -BEGIN - IF p_brand_id IS NULL THEN - RAISE EXCEPTION 'brand_id is required'; - END IF; - IF p_locations IS NULL OR jsonb_typeof(p_locations) <> 'array' OR jsonb_array_length(p_locations) = 0 THEN - RETURN '[]'::JSONB; - END IF; - - FOR v_row IN SELECT * FROM jsonb_array_elements(p_locations) LOOP - IF v_row->>'name' IS NULL OR length(trim(v_row->>'name')) = 0 THEN - RAISE EXCEPTION 'name is required for all locations'; - END IF; - - v_slug_base := lower(regexp_replace(trim(v_row->>'name'), '[^a-z0-9]+', '-', 'g')) - || COALESCE('-' || regexp_replace(COALESCE(v_row->>'zip', ''), '[^a-z0-9]+', '', 'gi'), ''); - v_slug_base := trim(BOTH '-' FROM v_slug_base); - v_slug := v_slug_base; - v_counter := 0; - WHILE EXISTS (SELECT 1 FROM locations WHERE brand_id = p_brand_id AND slug = v_slug AND deleted_at IS NULL) LOOP - v_counter := v_counter + 1; - v_slug := v_slug_base || '-' || v_counter; - END LOOP; - - INSERT INTO public.locations ( - brand_id, name, address, city, state, zip, phone, - contact_name, contact_email, notes, active, slug - ) VALUES ( - p_brand_id, - trim(v_row->>'name'), - NULLIF(trim(v_row->>'address'), ''), - NULLIF(trim(v_row->>'city'), ''), - NULLIF(trim(v_row->>'state'), ''), - NULLIF(trim(v_row->>'zip'), ''), - NULLIF(trim(v_row->>'phone'), ''), - NULLIF(trim(v_row->>'contact_name'), ''), - NULLIF(trim(v_row->>'contact_email'), ''), - NULLIF(trim(v_row->>'notes'), ''), - COALESCE((v_row->>'active')::BOOLEAN, true), - v_slug - ) - RETURNING id INTO v_id; - - v_result := v_result || jsonb_build_object('id', v_id, 'slug', v_slug)::JSONB; - END LOOP; - - RETURN v_result; -EXCEPTION WHEN OTHERS THEN - RAISE EXCEPTION 'admin_create_locations_batch failed: % (SQLSTATE %)', SQLERRM, SQLSTATE; -END; -$$; - --- ── 4c. admin_update_location ──────────────────────────────────────────────── --- Partial update: only fields present in p_updates (JSONB) are written. --- Example: SELECT admin_update_location('', '', '{"phone": "555-1234"}'); -CREATE OR REPLACE FUNCTION public.admin_update_location( - p_location_id UUID, - p_brand_id UUID, - p_updates JSONB -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_existing RECORD; - v_key TEXT; - v_value TEXT; -BEGIN - IF p_location_id IS NULL THEN - RAISE EXCEPTION 'location_id is required'; - END IF; - IF p_updates IS NULL OR jsonb_typeof(p_updates) <> 'object' THEN - RAISE EXCEPTION 'updates must be a JSON object'; - END IF; - - -- Lock + ownership check - SELECT * INTO v_existing FROM public.locations - WHERE id = p_location_id AND deleted_at IS NULL FOR UPDATE; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Location not found'); - END IF; - IF p_brand_id IS NOT NULL AND v_existing.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Location not found'); - END IF; - - -- Build dynamic UPDATE — only set fields explicitly present in p_updates - FOR v_key, v_value IN - SELECT key, value FROM jsonb_each_text(p_updates) - WHERE key IN ('name', 'address', 'city', 'state', 'zip', 'phone', - 'contact_name', 'contact_email', 'notes', 'active') - LOOP - EXECUTE format( - 'UPDATE public.locations SET %I = $1 WHERE id = $2', - v_key - ) USING v_value, p_location_id; - END LOOP; - - RETURN jsonb_build_object('success', true); -EXCEPTION WHEN OTHERS THEN - RAISE EXCEPTION 'admin_update_location failed: % (SQLSTATE %)', SQLERRM, SQLSTATE; -END; -$$; - --- ── 4d. admin_delete_location ──────────────────────────────────────────────── --- Soft delete via deleted_at. If any non-deleted stop still references this --- location, refuse — caller must reassign or hard-delete those stops first. -CREATE OR REPLACE FUNCTION public.admin_delete_location( - p_location_id UUID, - p_brand_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_loc RECORD; - v_stops_count INT; -BEGIN - SELECT * INTO v_loc FROM public.locations - WHERE id = p_location_id AND deleted_at IS NULL FOR UPDATE; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Location not found'); - END IF; - IF p_brand_id IS NOT NULL AND v_loc.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Location not found'); - END IF; - - SELECT COUNT(*) INTO v_stops_count FROM public.stops - WHERE location_id = p_location_id AND deleted_at IS NULL; - IF v_stops_count > 0 THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Cannot delete location — ' || v_stops_count || ' stop(s) still reference it. Reassign or delete those stops first.' - ); - END IF; - - UPDATE public.locations SET deleted_at = NOW() WHERE id = p_location_id; - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── 4e. get_locations_for_brand ────────────────────────────────────────────── --- Public read RPC. Mirrors get_public_stops_for_brand. --- Returns active, non-deleted locations for a brand slug. -CREATE OR REPLACE FUNCTION public.get_locations_for_brand(p_brand_slug TEXT) -RETURNS TABLE ( - id UUID, - name TEXT, - address TEXT, - city TEXT, - state TEXT, - zip TEXT, - phone TEXT, - contact_name TEXT, - contact_email TEXT, - notes TEXT, - slug TEXT, - stop_count BIGINT -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN QUERY - SELECT - l.id, l.name, l.address, l.city, l.state, l.zip, l.phone, - l.contact_name, l.contact_email, l.notes, l.slug, - (SELECT COUNT(*) FROM stops s - WHERE s.location_id = l.id AND s.deleted_at IS NULL)::BIGINT AS stop_count - FROM locations l - INNER JOIN brands b ON l.brand_id = b.id - WHERE l.deleted_at IS NULL - AND l.active = true - AND b.slug = p_brand_slug - ORDER BY lower(l.name) ASC; -END; -$$; - --- ── 4f. admin_attach_location_to_stop ──────────────────────────────────────── --- One-way link: set stops.location_id. Idempotent. -CREATE OR REPLACE FUNCTION public.admin_attach_location_to_stop( - p_stop_id UUID, - p_location_id UUID, - p_brand_id UUID DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_stop RECORD; - v_location RECORD; -BEGIN - SELECT * INTO v_stop FROM stops - WHERE id = p_stop_id AND deleted_at IS NULL FOR UPDATE; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - IF p_brand_id IS NOT NULL AND v_stop.brand_id != p_brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - SELECT * INTO v_location FROM locations - WHERE id = p_location_id AND deleted_at IS NULL; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Location not found'); - END IF; - IF v_location.brand_id != v_stop.brand_id THEN - RETURN jsonb_build_object('success', false, 'error', 'Location belongs to a different brand'); - END IF; - - UPDATE stops SET location_id = p_location_id WHERE id = p_stop_id; - -- Also denormalize the location name + address from the venue so the - -- storefront cards stay useful even when stops.location_id is unset. - UPDATE stops SET location = v_location.name, address = COALESCE(v_location.address, stops.address) - WHERE id = p_stop_id; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ============================================================================= --- 5. Grants --- ============================================================================= - -GRANT EXECUTE ON FUNCTION public.admin_create_location( - UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN -) TO anon, authenticated, service_role; - -GRANT EXECUTE ON FUNCTION public.admin_create_locations_batch(UUID, JSONB) - TO anon, authenticated, service_role; - -GRANT EXECUTE ON FUNCTION public.admin_update_location(UUID, UUID, JSONB) - TO anon, authenticated, service_role; - -GRANT EXECUTE ON FUNCTION public.admin_delete_location(UUID, UUID) - TO anon, authenticated, service_role; - -GRANT EXECUTE ON FUNCTION public.get_locations_for_brand(TEXT) - TO anon, authenticated, service_role; - -GRANT EXECUTE ON FUNCTION public.admin_attach_location_to_stop(UUID, UUID, UUID) - TO anon, authenticated, service_role; - -COMMIT; diff --git a/supabase/migrations/.archived/203_plan_usage_active_products.sql b/supabase/migrations/.archived/203_plan_usage_active_products.sql deleted file mode 100644 index 3011f0f..0000000 --- a/supabase/migrations/.archived/203_plan_usage_active_products.sql +++ /dev/null @@ -1,82 +0,0 @@ --- 203_plan_usage_active_products.sql --- Reconcile plan usage product count with the dashboard "Active Products" stat. --- --- Context: --- Migration 091's get_brand_plan_info counted `products` using --- deleted_at IS NULL --- while the dashboard's getDashboardStats server action counted --- active = true --- These two filters can disagree (a product can be `active=false` but --- not soft-deleted, or `active=true` and soft-deleted in some flows), --- producing "Active Products 1" in the dashboard stats while the --- billing/usage bar said "Products 0/25". --- --- This migration updates get_brand_plan_info to count products that are --- active AND not soft-deleted — the natural meaning of "products you sell --- that count against the plan limit". The same filter is also applied to --- the stop count (active AND not soft-deleted AND in the current month) --- to keep the usage semantics consistent across all three counters. --- --- Uses CREATE OR REPLACE FUNCTION so re-running is safe. - -CREATE OR REPLACE FUNCTION public.get_brand_plan_info(p_brand_id UUID) -RETURNS JSONB AS $$ -DECLARE - v_result JSONB; - v_user_count BIGINT; - v_active_stops BIGINT; - v_product_count BIGINT; - v_brand RECORD; -BEGIN - SELECT plan_tier, max_users, max_stops_monthly, max_products, stripe_customer_id, name - INTO v_brand - FROM brands WHERE id = p_brand_id; - - IF v_brand IS NULL THEN - RETURN NULL; - END IF; - - -- Count current usage with consistent semantics: - -- users: not soft-deleted - -- stops: active AND not soft-deleted AND in the current month - -- products: active AND not soft-deleted (matches "Active Products" - -- stat used in the admin dashboard, so plan usage and - -- dashboard stats never disagree) - SELECT COUNT(*) INTO v_user_count - FROM admin_users - WHERE brand_id = p_brand_id - AND deleted_at IS NULL; - - SELECT COUNT(*) INTO v_active_stops - FROM stops - WHERE brand_id = p_brand_id - AND active = true - AND deleted_at IS NULL - AND date >= date_trunc('month', now()); - - SELECT COUNT(*) INTO v_product_count - FROM products - WHERE brand_id = p_brand_id - AND active = true - AND deleted_at IS NULL; - - SELECT jsonb_build_object( - 'plan_tier', v_brand.plan_tier, - 'max_users', v_brand.max_users, - 'max_stops_monthly', v_brand.max_stops_monthly, - 'max_products', v_brand.max_products, - 'stripe_customer_id', v_brand.stripe_customer_id, - 'brand_name', v_brand.name, - 'usage', jsonb_build_object( - 'users', v_user_count, - 'stops_this_month', v_active_stops, - 'products', v_product_count - ) - ) INTO v_result; - - RETURN v_result; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Reload PostgREST schema cache so the change is picked up immediately. -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/204_admin_users_email_and_auth_subject.sql b/supabase/migrations/.archived/204_admin_users_email_and_auth_subject.sql deleted file mode 100644 index ebef4e8..0000000 --- a/supabase/migrations/.archived/204_admin_users_email_and_auth_subject.sql +++ /dev/null @@ -1,214 +0,0 @@ --- 204_admin_users_email_and_auth_subject.sql --- --- Prepare `admin_users` for Auth.js v5 multi-provider sign-in. --- --- Context: --- The platform is migrating from a Supabase-only auth flow to Auth.js v5 --- with multiple providers (Google, Supabase password). With Auth.js, a --- sign-in "subject" is the provider's stable user identifier: --- - Supabase password: a UUID (matches `auth.users.id`). --- - Google OAuth: an opaque string (the Google `sub` claim). --- The existing `admin_users.user_id UUID` column can only hold Supabase --- UUIDs, so Google sign-ins have nowhere to land. We also need the --- `admin_users` row to carry an `email` directly so server-side lookups --- no longer have to JOIN `auth.users` (which won't exist for Google users --- anyway). --- --- This migration: --- 1. Adds `email`, `auth_provider`, `auth_subject` columns. --- 2. Backfills `email` + `auth_provider` for existing Supabase rows. --- 3. Adds a unique index on `(auth_provider, auth_subject)` so the same --- Google subject can't be provisioned twice. --- 4. Replaces `upsert_admin_user` with a version that accepts email + --- provider + subject and can mint first-time Google sign-ins. --- 5. Adds `get_admin_user_for_session` so the application layer can --- resolve an Auth.js `session.user.id` to an admin row in one call --- (no `auth.users` join required). --- --- This is idempotent — re-running is safe. - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. Schema additions --- ═══════════════════════════════════════════════════════════════════════════ - -ALTER TABLE public.admin_users - ADD COLUMN IF NOT EXISTS email TEXT, - ADD COLUMN IF NOT EXISTS auth_provider TEXT, - ADD COLUMN IF NOT EXISTS auth_subject TEXT; - --- Backfill: pull email + mark existing rows as Supabase-originated. -UPDATE public.admin_users au -SET - email = COALESCE(au.email, u.email), - auth_provider = COALESCE(au.auth_provider, 'supabase') -FROM auth.users u -WHERE au.user_id = u.id - AND (au.email IS NULL OR au.auth_provider IS NULL); - --- Backfill the remaining rows (no auth.users match) so the NOT NULL --- constraint below doesn't reject them. These are likely orphaned test --- rows; flag them with provider='supabase-orphan' for visibility. -UPDATE public.admin_users -SET - email = COALESCE(email, 'unknown-' || id::TEXT || '@orphan.local'), - auth_provider = COALESCE(auth_provider, 'supabase-orphan') -WHERE email IS NULL OR auth_provider IS NULL; - --- Enforce that every row carries an email and a provider. The lookup --- RPCs rely on `email` being NOT NULL. -ALTER TABLE public.admin_users - ALTER COLUMN email SET NOT NULL, - ALTER COLUMN auth_provider SET NOT NULL; - --- CHECK: a row must have either a Supabase `user_id` (UUID) or an --- `auth_subject` (Google etc.). This catches the case where a row is --- accidentally inserted with neither. -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'admin_users_subject_check' - ) THEN - ALTER TABLE public.admin_users - ADD CONSTRAINT admin_users_subject_check - CHECK (user_id IS NOT NULL OR auth_subject IS NOT NULL); - END IF; -END $$; - --- Index: fast lookup by Auth.js session id (which is `auth_subject` for --- Google, `user_id` for Supabase). -CREATE INDEX IF NOT EXISTS admin_users_user_id_idx - ON public.admin_users (user_id) - WHERE user_id IS NOT NULL; - -CREATE INDEX IF NOT EXISTS admin_users_auth_subject_idx - ON public.admin_users (auth_provider, auth_subject) - WHERE auth_subject IS NOT NULL; - --- Unique: prevent the same Google subject from being provisioned twice. -CREATE UNIQUE INDEX IF NOT EXISTS admin_users_auth_subject_unique - ON public.admin_users (auth_provider, auth_subject) - WHERE auth_subject IS NOT NULL; - --- Index: fast lookup by email (used for admin invites + future "sign in --- with email" flows). -CREATE INDEX IF NOT EXISTS admin_users_email_idx - ON public.admin_users (email); - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. upsert_admin_user — replace with multi-provider version --- ═══════════════════════════════════════════════════════════════════════════ - --- Drop the old single-arg version if it exists (Supabase may have --- auto-generated it). Idempotent. -DROP FUNCTION IF EXISTS public.upsert_admin_user(UUID); - -CREATE OR REPLACE FUNCTION public.upsert_admin_user( - p_user_id UUID DEFAULT NULL, - p_email TEXT DEFAULT NULL, - p_auth_provider TEXT DEFAULT 'supabase', - p_auth_subject TEXT DEFAULT NULL -) -RETURNS public.admin_users -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_existing public.admin_users; - v_inserted public.admin_users; -BEGIN - -- 1. Find an existing row by the strongest signal we have. - IF p_auth_subject IS NOT NULL AND p_auth_provider <> 'supabase' THEN - SELECT * INTO v_existing - FROM public.admin_users - WHERE auth_provider = p_auth_provider - AND auth_subject = p_auth_subject - LIMIT 1; - ELSIF p_user_id IS NOT NULL THEN - SELECT * INTO v_existing - FROM public.admin_users - WHERE user_id = p_user_id - LIMIT 1; - ELSIF p_email IS NOT NULL THEN - SELECT * INTO v_existing - FROM public.admin_users - WHERE lower(email) = lower(p_email) - LIMIT 1; - END IF; - - IF v_existing.id IS NOT NULL THEN - -- 2a. Update missing fields in place (e.g. fill in `email` if the - -- row was orphaned at provision time). - UPDATE public.admin_users - SET - email = COALESCE(email, p_email), - auth_provider = COALESCE(NULLIF(auth_provider, 'supabase-orphan'), p_auth_provider), - auth_subject = COALESCE(auth_subject, p_auth_subject), - user_id = COALESCE(user_id, p_user_id) - WHERE id = v_existing.id - RETURNING * INTO v_existing; - RETURN v_existing; - END IF; - - -- 2b. First-time sign-in. Insert a platform_admin row. Active is - -- true so the user can sign in immediately; brand assignment is - -- a manual step the platform admin takes later. - INSERT INTO public.admin_users ( - user_id, email, auth_provider, auth_subject, role, active, - can_manage_products, can_manage_stops, can_manage_orders, - can_manage_pickup, can_manage_messages, can_manage_refunds, - can_manage_users, can_manage_water_log, can_manage_reports, - must_change_password - ) VALUES ( - p_user_id, p_email, p_auth_provider, p_auth_subject, - 'platform_admin', true, - true, true, true, true, true, true, true, true, true, - false - ) - RETURNING * INTO v_inserted; - - RETURN v_inserted; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. get_admin_user_for_session — Auth.js session resolver --- ═══════════════════════════════════════════════════════════════════════════ - --- Returns the admin row matching an Auth.js `session.user.id`, looking --- up by `user_id` (Supabase UUID) OR `auth_subject` (Google etc.). --- Returns NULL on miss — the application layer decides whether to --- auto-provision a new row. -DROP FUNCTION IF EXISTS public.get_admin_user_for_session(TEXT); - -CREATE OR REPLACE FUNCTION public.get_admin_user_for_session(p_session_id TEXT) -RETURNS public.admin_users -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_row public.admin_users; -BEGIN - -- Try Supabase path first (UUID match on user_id). - IF p_session_id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN - SELECT * INTO v_row - FROM public.admin_users - WHERE user_id = p_session_id::UUID - LIMIT 1; - IF v_row.id IS NOT NULL THEN - RETURN v_row; - END IF; - END IF; - - -- Fallback: treat the session id as a provider `auth_subject`. The - -- provider is unknown at this point, so match on the subject alone - -- (still unique thanks to the partial unique index). - SELECT * INTO v_row - FROM public.admin_users - WHERE auth_subject = p_session_id - LIMIT 1; - - RETURN v_row; -END; -$$; diff --git a/supabase/migrations/.archived/204_authjs_tables.sql b/supabase/migrations/.archived/204_authjs_tables.sql deleted file mode 100644 index 7bb827b..0000000 --- a/supabase/migrations/.archived/204_authjs_tables.sql +++ /dev/null @@ -1,73 +0,0 @@ --- ============================================================ --- Auth.js (NextAuth v5) tables --- ============================================================ --- Schema expected by @auth/pg-adapter. --- Reference: https://authjs.dev/getting-started/adapters/pg --- --- Column names are kept as the adapter expects them (case-sensitive, --- camelCase, quoted). Do NOT rename these without also updating the --- adapter code in node_modules/@auth/pg-adapter. --- --- We use UUIDs for user ids (consistent with the rest of the platform) --- rather than SERIAL. The adapter doesn't care what type `id` is — it --- just passes the value through. --- ============================================================ - --- ── users ───────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT, - email TEXT UNIQUE, - "emailVerified" TIMESTAMPTZ, - image TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- ── accounts ───────────────────────────────────────────────── --- One row per (provider, providerAccountId). Links external OAuth --- accounts to a local user. -CREATE TABLE IF NOT EXISTS accounts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - "userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - type TEXT NOT NULL, - provider TEXT NOT NULL, - "providerAccountId" TEXT NOT NULL, - refresh_token TEXT, - access_token TEXT, - expires_at BIGINT, - id_token TEXT, - scope TEXT, - session_state TEXT, - token_type TEXT, - UNIQUE (provider, "providerAccountId") -); - -CREATE INDEX IF NOT EXISTS accounts_userid_idx ON accounts ("userId"); - --- ── sessions ───────────────────────────────────────────────── --- One row per active session. With database strategy enabled, the --- session token is stored here and looked up on every request. -CREATE TABLE IF NOT EXISTS sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - "userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - expires TIMESTAMPTZ NOT NULL, - "sessionToken" TEXT NOT NULL UNIQUE -); - -CREATE INDEX IF NOT EXISTS sessions_userid_idx ON sessions ("userId"); -CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions (expires); - --- ── verification_token ────────────────────────────────────── --- Used for email magic-link / passwordless flows. Not used by the --- Google provider, but the adapter still references the table. -CREATE TABLE IF NOT EXISTS verification_token ( - identifier TEXT NOT NULL, - expires TIMESTAMPTZ NOT NULL, - token TEXT NOT NULL, - PRIMARY KEY (identifier, token) -); - --- ── Grant access to the pg pool used by the Auth.js adapter ── --- (No-op if you're connecting as the table owner; included for --- completeness in case a separate app role is used.) --- GRANT SELECT, INSERT, UPDATE, DELETE ON users, accounts, sessions, verification_token TO authenticator; diff --git a/supabase/migrations/.archived/204_locations_public_read_policy.sql b/supabase/migrations/.archived/204_locations_public_read_policy.sql deleted file mode 100644 index 3b31130..0000000 --- a/supabase/migrations/.archived/204_locations_public_read_policy.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Migration 204: Add permissive read policy for locations --- --- Why: The admin Stops & Routes page reads stops via the Supabase JS client --- (anon key + RLS). The `stops` table has a `Public read stops` policy with --- qual=true, which lets the anon role see all non-deleted stops — that's how --- the page works in dev mode (no Supabase auth). The admin_locations tab needs --- the same behavior so the server component can use the same pattern: --- supabase.from("locations").select(...) --- --- Brand scoping is enforced at the application layer in the server component --- (eq("brand_id", adminUser.brand_id)), matching how stops are scoped. --- --- Brand-scoped public reads (storefront pages) go through get_locations_for_brand --- RPC, which filters active=true. - -DROP POLICY IF EXISTS "Public read locations" ON public.locations; -CREATE POLICY "Public read locations" ON public.locations - FOR SELECT - USING (true); diff --git a/supabase/migrations/.archived/205_admin_list_locations.sql b/supabase/migrations/.archived/205_admin_list_locations.sql deleted file mode 100644 index 2097d7e..0000000 --- a/supabase/migrations/.archived/205_admin_list_locations.sql +++ /dev/null @@ -1,57 +0,0 @@ --- Migration 205: admin_list_locations RPC --- --- Returns ALL non-deleted locations for a brand (including inactive) with a --- stop_count aggregation. Mirrors get_locations_for_brand but is brand_id- --- keyed and admin-side (no active filter) so the Locations tab in the admin --- Stops & Routes page can show inactive venues and draft venues. --- --- brand_id NULL means platform_admin scope (all brands). This is the --- standard application-layer brand-scoping pattern (SECURITY DEFINER RPCs --- bypass RLS; caller is responsible for authorization). - -CREATE OR REPLACE FUNCTION public.admin_list_locations( - p_brand_id UUID DEFAULT NULL -) -RETURNS TABLE ( - id UUID, - brand_id UUID, - name TEXT, - address TEXT, - city TEXT, - state TEXT, - zip TEXT, - phone TEXT, - contact_name TEXT, - contact_email TEXT, - notes TEXT, - active BOOLEAN, - created_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ, - deleted_at TIMESTAMPTZ, - slug TEXT, - stop_count BIGINT -) -LANGUAGE sql -SECURITY DEFINER -STABLE -SET search_path = public -AS $$ - SELECT - l.id, l.brand_id, l.name, l.address, l.city, l.state, l.zip, - l.phone, l.contact_name, l.contact_email, l.notes, l.active, - l.created_at, l.updated_at, l.deleted_at, l.slug, - COALESCE(s.cnt, 0) AS stop_count - FROM public.locations l - LEFT JOIN ( - SELECT location_id, COUNT(*) AS cnt - FROM public.stops - WHERE deleted_at IS NULL - GROUP BY location_id - ) s ON s.location_id = l.id - WHERE l.deleted_at IS NULL - AND (p_brand_id IS NULL OR l.brand_id = p_brand_id) - ORDER BY lower(l.name) ASC; -$$; - -GRANT EXECUTE ON FUNCTION public.admin_list_locations(UUID) - TO anon, authenticated, service_role; diff --git a/supabase/migrations/.archived/206_locations_rededupe_by_city.sql b/supabase/migrations/.archived/206_locations_rededupe_by_city.sql deleted file mode 100644 index 4bc4e6c..0000000 --- a/supabase/migrations/.archived/206_locations_rededupe_by_city.sql +++ /dev/null @@ -1,142 +0,0 @@ --- Migration 206: Re-dedupe locations by (name, city, address) --- --- Problem --- ------- --- The original backfill (after migration 203) grouped venues by (name, address) --- only — so "Tractor Supply" in Brighton, CO and "Tractor Supply" in --- Canon City, CO both got rolled up to one location if they happened to share --- the same address (or both had a NULL address). The same affected: --- * Tractor Supply — 58 stops at 10 distinct (city, address) combos --- * Ace Hardware — 27 stops at 3 distinct combos --- * Murdoch's Ranch & Home — 25 stops at 3 distinct combos --- * Big Tool Box — 15 stops at 2 distinct combos --- * JAX Farm & Ranch — 13 stops at 2 distinct combos --- Result: 5 of the 26 venues were each represented as a single over-broad --- location record, hiding the real per-city venue from the Locations tab. --- --- Fix --- --- --- Re-dedupe by (name, city, address). For each unique combo, create a fresh --- location row, re-link stops to it, and soft-delete the over-grouped originals --- (which by then have zero stop references). Idempotent: safe to re-run; on --- re-run the CTE returns no rows and the script is a no-op. --- --- Result: 41 locations for the Tuxedo brand (up from 26). 269 stops still --- linked, just to the correct per-city venue record. - -DO $$ -DECLARE - v_brand_id UUID := '64294306-5f42-463d-a5e8-2ad6c81a96de'; - v_inserted INT := 0; - v_relinked INT := 0; - v_soft_del INT := 0; -BEGIN - -- 1. Insert one new location per distinct (name, city, address) tuple that - -- isn't already a current non-deleted location. Skip if a row with the - -- same (brand_id, name, lower(city), lower(address)) already exists. - WITH venues AS ( - SELECT DISTINCT - s.brand_id, - s.location AS name, - NULLIF(btrim(COALESCE(s.city, '')), '') AS city, - NULLIF(btrim(COALESCE(s.address, '')), '') AS address - FROM public.stops s - WHERE s.brand_id = v_brand_id - AND s.deleted_at IS NULL - AND s.location IS NOT NULL - AND length(trim(s.location)) > 0 - ), - ranked AS ( - -- Stable, per-(name, city, address) ordering so slugs are reproducible - SELECT - v.*, - row_number() OVER ( - PARTITION BY v.brand_id, lower(trim(v.name)) - ORDER BY lower(COALESCE(v.city, '')), lower(COALESCE(v.address, '')) - ) AS rn - FROM venues v - ), - inserted AS ( - INSERT INTO public.locations ( - brand_id, name, city, address, active, slug - ) - SELECT - r.brand_id, - trim(r.name), - r.city, - r.address, - true, - -- Slug: name, optionally suffixed with -city, then a numeric counter - -- only when multiple venues share the same name (e.g. Tractor Supply - -- appears in 10 cities, so we get -1, -2, … -10). - -- Use [[:alnum:]] so uppercase letters aren't stripped (e.g. "Tractor - -- Supply" → "tractor-supply", not "ractor-upply"). - trim(BOTH '-' FROM lower(regexp_replace(trim(r.name), '[^[:alnum:]]+', '-', 'g'))) - || CASE - WHEN r.city IS NOT NULL AND length(trim(r.city)) > 0 - THEN '-' || trim(BOTH '-' FROM lower(regexp_replace(trim(r.city), '[^[:alnum:]]+', '-', 'g'))) - ELSE '' - END - || CASE WHEN r.rn > 1 THEN '-' || r.rn::text ELSE '' END - FROM ranked r - WHERE NOT EXISTS ( - SELECT 1 FROM public.locations l - WHERE l.brand_id = r.brand_id - AND l.name = trim(r.name) - AND l.deleted_at IS NULL - AND lower(COALESCE(l.city, '')) = lower(COALESCE(r.city, '')) - AND lower(COALESCE(l.address, '')) = lower(COALESCE(r.address, '')) - ) - RETURNING id, name, city, address - ) - SELECT COUNT(*) INTO v_inserted FROM inserted; - RAISE NOTICE 'Inserted % new per-city locations', v_inserted; - - -- 2. Re-link each stop to the location whose (name, city, address) matches. - -- Use lower() on both sides for case-insensitive matching. - WITH new_links AS ( - SELECT - s.id AS stop_id, - l.id AS location_id - FROM public.stops s - JOIN public.locations l - ON l.brand_id = s.brand_id - AND lower(trim(l.name)) = lower(trim(s.location)) - AND lower(COALESCE(l.city, '')) = lower(COALESCE(NULLIF(btrim(s.city), ''), '')) - AND lower(COALESCE(l.address, '')) = lower(COALESCE(NULLIF(btrim(s.address), ''), '')) - AND l.deleted_at IS NULL - WHERE s.brand_id = v_brand_id - AND s.deleted_at IS NULL - ) - UPDATE public.stops s - SET location_id = nl.location_id - FROM new_links nl - WHERE s.id = nl.stop_id; - - GET DIAGNOSTICS v_relinked = ROW_COUNT; - RAISE NOTICE 'Re-linked % stops to their per-city locations', v_relinked; - - -- 3. Soft-delete any location that no longer has a non-deleted stop pointing - -- to it. (These are the over-grouped originals from the previous dedupe.) - UPDATE public.locations l - SET deleted_at = NOW() - WHERE l.brand_id = v_brand_id - AND l.deleted_at IS NULL - AND NOT EXISTS ( - SELECT 1 FROM public.stops s - WHERE s.location_id = l.id - AND s.deleted_at IS NULL - ); - - GET DIAGNOSTICS v_soft_del = ROW_COUNT; - RAISE NOTICE 'Soft-deleted % now-orphan locations', v_soft_del; -END$$; - --- Final summary -SELECT - (SELECT COUNT(*) FROM public.locations - WHERE brand_id = '64294306-5f42-463d-a5e8-2ad6c81a96de' AND deleted_at IS NULL) AS locations, - (SELECT COUNT(*) FROM public.stops - WHERE brand_id = '64294306-5f42-463d-a5e8-2ad6c81a96de' AND deleted_at IS NULL AND location_id IS NOT NULL) AS linked_stops, - (SELECT COUNT(*) FROM public.locations - WHERE brand_id = '64294306-5f42-463d-a5e8-2ad6c81a96de' AND deleted_at IS NOT NULL) AS soft_deleted; diff --git a/supabase/migrations/.archived/207_multi_brand_admin.sql b/supabase/migrations/.archived/207_multi_brand_admin.sql deleted file mode 100644 index 4719139..0000000 --- a/supabase/migrations/.archived/207_multi_brand_admin.sql +++ /dev/null @@ -1,185 +0,0 @@ --- Migration 207: Multi-Brand Admin Support --- --- Adds: --- 1. New role: 'multi_brand_admin' (functionally equivalent to brand_admin, --- but disambiguates intent: "manages 2+ brands" vs "manages 1 brand"). --- 2. Junction table: admin_user_brands — replaces the single brand_id --- implicit invariant. The legacy admin_users.brand_id column is kept --- for backwards compatibility (see migration 220_*.sql to drop it later). --- 3. RPCs: add_admin_user_brand / remove_admin_user_brand (manages the --- junction and auto-promotes/demotes the role based on brand count). --- --- Design: --- - Composite PK (admin_user_id, brand_id) enforces uniqueness. --- - Index on brand_id makes "which admins are in Brand X?" queries fast. --- - ON DELETE CASCADE for both FKs — deleting an admin or brand cleans up. --- - added_by is nullable so first-insert rows (admin with no other admin) --- can still insert their own membership. - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. Update role CHECK constraint to include multi_brand_admin --- ═══════════════════════════════════════════════════════════════════════════ --- The constraint name in Supabase's auto-generated schema is conventionally --- "admin_users_role_check" (the default Postgres naming), but we drop both --- the canonical and a common alternative to be safe across schema generations. - -ALTER TABLE public.admin_users DROP CONSTRAINT IF EXISTS admin_users_role_check; -ALTER TABLE public.admin_users DROP CONSTRAINT IF EXISTS admin_users_role_chk; - -ALTER TABLE public.admin_users - ADD CONSTRAINT admin_users_role_check - CHECK (role IN ('platform_admin', 'brand_admin', 'multi_brand_admin', 'store_employee', 'staff')); - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. Create admin_user_brands junction table --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS public.admin_user_brands ( - admin_user_id UUID NOT NULL REFERENCES public.admin_users(id) ON DELETE CASCADE, - brand_id UUID NOT NULL REFERENCES public.brands(id) ON DELETE CASCADE, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - added_by UUID REFERENCES public.admin_users(id), - PRIMARY KEY (admin_user_id, brand_id) -); - -CREATE INDEX IF NOT EXISTS admin_user_brands_brand_id_idx - ON public.admin_user_brands(brand_id); - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. Backfill: copy legacy single-brand admins into the junction --- ═══════════════════════════════════════════════════════════════════════════ - -INSERT INTO public.admin_user_brands (admin_user_id, brand_id) -SELECT id, brand_id FROM public.admin_users -WHERE brand_id IS NOT NULL -ON CONFLICT (admin_user_id, brand_id) DO NOTHING; - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. Promote anyone with > 1 brand in the junction to multi_brand_admin --- ═══════════════════════════════════════════════════════════════════════════ - -UPDATE public.admin_users -SET role = 'multi_brand_admin' -WHERE role = 'brand_admin' - AND id IN ( - SELECT admin_user_id FROM public.admin_user_brands - GROUP BY admin_user_id HAVING COUNT(*) > 1 - ); - --- ═══════════════════════════════════════════════════════════════════════════ --- 5. RLS: enable on the junction (no public read; only platform_admin all) --- ═══════════════════════════════════════════════════════════════════════════ - -ALTER TABLE public.admin_user_brands ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "admin_user_brands_platform_admin_all" ON public.admin_user_brands; -CREATE POLICY "admin_user_brands_platform_admin_all" ON public.admin_user_brands - FOR ALL TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - AND au.active = true - ) - ); - --- Brand admins can read their own membership rows (so the BrandSelector --- dropdown works without exposing other admins' memberships) -DROP POLICY IF EXISTS "admin_user_brands_self_read" ON public.admin_user_brands; -CREATE POLICY "admin_user_brands_self_read" ON public.admin_user_brands - FOR SELECT TO authenticated - USING ( - admin_user_id IN ( - SELECT id FROM public.admin_users - WHERE user_id = auth.uid() AND active = true - ) - ); - --- ═══════════════════════════════════════════════════════════════════════════ --- 6. RPC: add_admin_user_brand --- Adds an admin to a brand. Auto-promotes to multi_brand_admin if they --- end up with 2+ brands. --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.add_admin_user_brand( - p_admin_user_id UUID, - p_brand_id UUID, - p_added_by UUID DEFAULT NULL -) -RETURNS VOID -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - INSERT INTO public.admin_user_brands (admin_user_id, brand_id, added_by) - VALUES (p_admin_user_id, p_brand_id, p_added_by) - ON CONFLICT (admin_user_id, brand_id) DO NOTHING; - - -- Promote to multi_brand_admin if they now have 2+ brands - UPDATE public.admin_users - SET role = 'multi_brand_admin' - WHERE id = p_admin_user_id - AND role = 'brand_admin' - AND ( - SELECT COUNT(*) FROM public.admin_user_brands - WHERE admin_user_id = p_admin_user_id - ) > 1; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 7. RPC: remove_admin_user_brand --- Removes an admin from a brand. Auto-demotes to brand_admin if they --- drop to 1 brand. Does NOT demote to anything else (e.g. staff) — only --- brand_admin <-> multi_brand_admin transitions. --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.remove_admin_user_brand( - p_admin_user_id UUID, - p_brand_id UUID -) -RETURNS VOID -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_remaining_count INT; -BEGIN - DELETE FROM public.admin_user_brands - WHERE admin_user_id = p_admin_user_id - AND brand_id = p_brand_id; - - GET DIAGNOSTICS v_remaining_count = ROW_COUNT; - - -- If we just removed a row and the user is now down to 1 brand, demote - IF v_remaining_count > 0 THEN - SELECT COUNT(*) INTO v_remaining_count - FROM public.admin_user_brands - WHERE admin_user_id = p_admin_user_id; - - IF v_remaining_count = 1 THEN - UPDATE public.admin_users - SET role = 'brand_admin' - WHERE id = p_admin_user_id - AND role = 'multi_brand_admin'; - END IF; - END IF; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 8. Refresh PostgREST schema cache --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/208_stops_date_to_date.sql b/supabase/migrations/.archived/208_stops_date_to_date.sql deleted file mode 100644 index 415bf1b..0000000 --- a/supabase/migrations/.archived/208_stops_date_to_date.sql +++ /dev/null @@ -1,73 +0,0 @@ --- Migration 208: Change stops.date from TIMESTAMPTZ to DATE --- --- Why --- --- --- The `stops.date` column was created as TIMESTAMPTZ but is used throughout --- the app as a calendar date (no time-of-day component). The mismatch --- forces every client consumer to do `new Date(s.date + "T00:00:00")` to --- coerce the timestamp into a local-midnight Date — and that hack silently --- produces an Invalid Date when Supabase returns a `2026-06-15 00:00:00+00` --- style string, leaving the Stops & Routes calendar empty. --- --- Storing the column as DATE makes Supabase return a clean `YYYY-MM-DD` --- string, which the existing `+ "T00:00:00"` parsing handles correctly. --- --- The `time` (TEXT, "HH:MM") and `cutoff_time` (TIMESTAMPTZ) columns are --- unchanged — they legitimately carry time-of-day information. --- --- Affected RPC --- ------------- --- `get_public_stops_for_brand` declares `date TIMESTAMPTZ` in its --- RETURNS TABLE. Recreate it with `date DATE` so the return type matches --- the underlying column. - --- 1. Drop the existing RPC so we can recreate it with a new return type. --- PostgreSQL refuses CREATE OR REPLACE when the OUT-parameter row type --- changes (error 42P13). -DROP FUNCTION IF EXISTS get_public_stops_for_brand(TEXT); - --- 2. Convert the column. USING date::DATE truncates the time component, --- which is what we want — all existing rows are stored at 00:00 anyway. -ALTER TABLE public.stops - ALTER COLUMN date TYPE DATE USING date::DATE; - --- 3. Recreate the public RPC with the corrected return type. -CREATE OR REPLACE FUNCTION get_public_stops_for_brand(p_brand_slug TEXT) -RETURNS TABLE ( - id UUID, - city TEXT, - state TEXT, - date DATE, - "time" TEXT, - location TEXT, - address TEXT, - slug TEXT, - cutoff_time TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN QUERY - SELECT - s.id, - s.city, - s.state, - s.date, - s."time", - s.location, - s.address, - s.slug, - s.cutoff_time - FROM stops s - INNER JOIN brands b ON s.brand_id = b.id - WHERE s.active = true - AND b.slug = p_brand_slug - ORDER BY s.date ASC; -END; -$$; - -GRANT EXECUTE ON FUNCTION get_public_stops_for_brand(TEXT) TO anon; -GRANT EXECUTE ON FUNCTION get_public_stops_for_brand(TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION get_public_stops_for_brand(TEXT) TO service_role; diff --git a/supabase/migrations/.archived/209_authjs_auto_create_admin.sql b/supabase/migrations/.archived/209_authjs_auto_create_admin.sql deleted file mode 100644 index fd80621..0000000 --- a/supabase/migrations/.archived/209_authjs_auto_create_admin.sql +++ /dev/null @@ -1,95 +0,0 @@ --- 209_authjs_auto_create_admin.sql --- Auto-create a platform_admin row when a new user signs in via Auth.js. --- --- Called from the `signIn` event in `src/lib/auth.ts`. The RPC is --- idempotent (ON CONFLICT DO NOTHING) so repeat sign-ins are no-ops. - --- Defensive: ensure can_manage_settings column exists. It was likely --- added via the Supabase dashboard (it's referenced in the TypeScript --- `AdminUser` type at `src/lib/admin-permissions-types.ts` but not in --- any tracked migration). ADD COLUMN IF NOT EXISTS is safe to re-run. -ALTER TABLE admin_users - ADD COLUMN IF NOT EXISTS can_manage_settings BOOLEAN NOT NULL DEFAULT false; - --- Defensive: ensure admin_users.user_id has a unique constraint so the --- `ON CONFLICT (user_id)` below resolves. The table was created via the --- Supabase dashboard — we can't be sure the dashboard created a UNIQUE --- index on user_id. If the constraint is missing, the ON CONFLICT --- clause will fail the whole "Apply migrations" step on the deploy --- runner. Skip silently if a matching unique/primary constraint already --- exists, otherwise add one (cleaning up any duplicate rows first so --- the ADD CONSTRAINT doesn't fail). -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conrelid = 'public.admin_users'::regclass - AND contype IN ('u', 'p') -- unique or primary key - AND pg_get_constraintdef(oid) ILIKE '%(user_id)%' - ) THEN - -- Shouldn't happen in practice (this RPC is the only writer for new - -- rows), but guard against duplicate user_id values that would - -- block the unique constraint from being created. - DELETE FROM admin_users a - USING admin_users b - WHERE a.user_id = b.user_id - AND a.ctid > b.ctid; - ALTER TABLE admin_users - ADD CONSTRAINT admin_users_user_id_key UNIQUE (user_id); - END IF; -END $$; - --- SECURITY DEFINER RPC: upsert a platform_admin row for the given --- Auth.js user id. --- --- Bypasses RLS on admin_users (which is enabled — see --- 109_enable_rls_critical.sql:21). Runs with the function owner's --- privileges so the auto-create on first sign-in can always succeed. -CREATE OR REPLACE FUNCTION upsert_admin_user_for_authjs(p_user_id UUID) -RETURNS SETOF admin_users -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN QUERY - INSERT INTO admin_users ( - user_id, - role, - active, - must_change_password, - can_manage_products, - can_manage_stops, - can_manage_orders, - can_manage_pickup, - can_manage_messages, - can_manage_refunds, - can_manage_users, - can_manage_water_log, - can_manage_reports, - can_manage_settings - ) - VALUES ( - p_user_id, - 'platform_admin', - true, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true - ) - ON CONFLICT (user_id) DO NOTHING - RETURNING *; -END; -$$; - --- Reload PostgREST schema cache so the new RPC is immediately callable. -NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/.archived/BUNDLE_018_042.sql b/supabase/migrations/.archived/BUNDLE_018_042.sql deleted file mode 100644 index b10b669..0000000 --- a/supabase/migrations/.archived/BUNDLE_018_042.sql +++ /dev/null @@ -1,4778 +0,0 @@ --- ─────────────────────────────────────────── --- 018_contact_import_metadata.sql --- ─────────────────────────────────────────── --- ============================================================================= --- Communication Center V1.2 — Import Metadata Storage --- Fully idempotent: additive changes only --- Updates import_communication_contacts_batch to store ignored CSV columns --- in metadata.imported_raw so no context is lost from arbitrary extra columns. --- ============================================================================= - -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- ── Update email path — add imported_raw to metadata on UPDATE ─────────────── --- Lines ~400-402 in migration 017: UPDATE path for existing email contact - -CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch( - p_brand_id UUID, - p_contacts JSONB, - p_allow_opt_in_override BOOLEAN DEFAULT false -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_entry JSONB; - v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB; - v_existing communication_contacts%ROWTYPE; - v_email TEXT; - v_phone TEXT; - v_tags TEXT[]; - v_raw_meta JSONB; -BEGIN - FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts) - LOOP - v_email := nullif(v_entry->>'email', ''); - v_phone := nullif(v_entry->>'phone', ''); - - -- Parse tags: CSV sends "tag1;tag2" as a plain string; split into TEXT[] - IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN - v_tags := coalesce( - (SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''), - '{}' - ); - ELSE - v_tags := '{}'; - END IF; - - -- Build imported_raw from any _metadata key (ignored CSV columns) - v_raw_meta := nullif(v_entry->>'_metadata', '')::JSONB; - - BEGIN - IF v_email IS NOT NULL AND v_email != '' THEN - SELECT * INTO v_existing - FROM public.communication_contacts - WHERE brand_id = p_brand_id AND email = v_email; - - IF FOUND THEN - -- Existing contact - IF v_existing.unsubscribed_at IS NOT NULL AND - coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND - p_allow_opt_in_override = false THEN - v_result := jsonb_set(v_result, '{skipped}', - to_jsonb((v_result->>'skipped')::INTEGER + 1)); - ELSE - UPDATE public.communication_contacts SET - phone = COALESCE(nullif(v_entry->>'phone', ''), phone), - first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name), - last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name), - full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name), - source = 'import', - external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id), - email_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.email_opt_in - ELSE coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) - END, - sms_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.sms_opt_in - ELSE coalesce( - (v_entry->>'sms_opt_in')::BOOLEAN, - communication_contacts.sms_opt_in, - false - ) - END, - email_opt_in_at = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN email_opt_in_at - WHEN coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) = true THEN now() - ELSE email_opt_in_at - END, - tags = CASE - WHEN v_tags != '{}' THEN v_tags - ELSE communication_contacts.tags - END, - metadata = communication_contacts.metadata || - jsonb_build_object( - 'imported_at', now()::TEXT, - 'imported_raw', coalesce(v_raw_meta, '{}'::JSONB) - ), - updated_at = now() - WHERE brand_id = p_brand_id AND email = v_email; - - v_result := jsonb_set(v_result, '{updated}', - to_jsonb((v_result->>'updated')::INTEGER + 1)); - END IF; - - ELSE - -- Insert new - INSERT INTO public.communication_contacts - (brand_id, email, phone, first_name, last_name, full_name, source, - external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at, - tags, metadata) - VALUES ( - p_brand_id, - v_email, - nullif(v_entry->>'phone', ''), - nullif(v_entry->>'first_name', ''), - nullif(v_entry->>'last_name', ''), - nullif(v_entry->>'full_name', ''), - 'import', - nullif(v_entry->>'external_id', ''), - coalesce((v_entry->>'email_opt_in')::BOOLEAN, true), - coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false), - CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END, - CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END, - v_tags, - jsonb_build_object( - 'imported_at', now()::TEXT, - 'imported_raw', coalesce(v_raw_meta, '{}'::JSONB) - ) - ); - v_result := jsonb_set(v_result, '{created}', - to_jsonb((v_result->>'created')::INTEGER + 1)); - END IF; - - ELSIF v_phone IS NOT NULL AND v_phone != '' THEN - -- Upsert by phone - SELECT * INTO v_existing - FROM public.communication_contacts - WHERE brand_id = p_brand_id AND phone = v_phone; - - IF FOUND THEN - IF v_existing.unsubscribed_at IS NOT NULL AND - coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND - p_allow_opt_in_override = false THEN - v_result := jsonb_set(v_result, '{skipped}', - to_jsonb((v_result->>'skipped')::INTEGER + 1)); - ELSE - UPDATE public.communication_contacts SET - email = COALESCE(nullif(v_entry->>'email', ''), email), - first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name), - last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name), - full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name), - source = 'import', - email_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.email_opt_in - ELSE coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) - END, - tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END, - metadata = communication_contacts.metadata || - jsonb_build_object( - 'imported_at', now()::TEXT, - 'imported_raw', coalesce(v_raw_meta, '{}'::JSONB) - ), - updated_at = now() - WHERE brand_id = p_brand_id AND phone = v_phone; - - v_result := jsonb_set(v_result, '{updated}', - to_jsonb((v_result->>'updated')::INTEGER + 1)); - END IF; - ELSE - INSERT INTO public.communication_contacts - (brand_id, email, phone, first_name, last_name, full_name, source, - email_opt_in, sms_opt_in, tags, metadata) - VALUES ( - p_brand_id, - nullif(v_entry->>'email', ''), - v_phone, - nullif(v_entry->>'first_name', ''), - nullif(v_entry->>'last_name', ''), - nullif(v_entry->>'full_name', ''), - 'import', - coalesce((v_entry->>'email_opt_in')::BOOLEAN, true), - coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false), - v_tags, - jsonb_build_object( - 'imported_at', now()::TEXT, - 'imported_raw', coalesce(v_raw_meta, '{}'::JSONB) - ) - ); - v_result := jsonb_set(v_result, '{created}', - to_jsonb((v_result->>'created')::INTEGER + 1)); - END IF; - - ELSE - -- No email or phone - v_result := jsonb_set( - v_result, '{errors}', - v_result->'errors' || jsonb_build_array( - jsonb_build_object('row', v_entry, 'error', 'No email or phone provided') - ) - ); - END IF; - - EXCEPTION WHEN OTHERS THEN - v_result := jsonb_set( - v_result, '{errors}', - v_result->'errors' || jsonb_build_array( - jsonb_build_object('row', v_entry, 'error', SQLERRM) - ) - ); - END; - END LOOP; - - RETURN v_result; -END; -$$; - -NOTIFY pgrst, 'reload schema'; - --- ─────────────────────────────────────────── --- 019_customers_table.sql --- ─────────────────────────────────────────── --- ============================================================================= --- V1.2 Stage 1 — Canonical Customers Table --- Fully idempotent: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS --- Purely additive: no changes to orders, communication_contacts, or checkout --- --- STAGE 2 NOTE: When upserting customers in Stage 2, normalize BEFORE upsert: --- - email: lowercase + trim --- - phone: strip formatting chars [\s\-().[\]], preserve original if uncertain --- This ensures consistent matching across orders, imports, and contacts. --- ============================================================================= - -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- ── Table ──────────────────────────────────────────────────────────────────── - -CREATE TABLE IF NOT EXISTS public.customers ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - primary_email TEXT, - primary_phone TEXT, - first_name TEXT, - last_name TEXT, - source TEXT NOT NULL DEFAULT 'system', - metadata JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT customers_email_or_phone CHECK ( - primary_email IS NOT NULL OR primary_phone IS NOT NULL - ) -); - --- ── Indexes ─────────────────────────────────────────────────────────────────── - -CREATE INDEX IF NOT EXISTS idx_customers_brand - ON public.customers(brand_id); -CREATE INDEX IF NOT EXISTS idx_customers_email - ON public.customers(primary_email) WHERE primary_email IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_customers_phone - ON public.customers(primary_phone) WHERE primary_phone IS NOT NULL; - --- Partial unique indexes: enforce one customer per brand per email/phone --- PostgreSQL requires CREATE UNIQUE INDEX rather than CONSTRAINT for partial unique -CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_email_unique - ON public.customers(brand_id, primary_email) - WHERE primary_email IS NOT NULL; - -CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_phone_unique - ON public.customers(brand_id, primary_phone) - WHERE primary_phone IS NOT NULL; - --- ── RLS ─────────────────────────────────────────────────────────────────────── - -ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Brand admin can read customers" - ON public.customers; -CREATE POLICY "Brand admin can read customers" - ON public.customers FOR SELECT TO authenticated - USING (brand_id IN ( - SELECT brand_id FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'brand_admin' - )); - -DROP POLICY IF EXISTS "Platform admin can read customers" - ON public.customers; -CREATE POLICY "Platform admin can read customers" - ON public.customers FOR SELECT TO authenticated - USING (EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - )); - -NOTIFY pgrst, 'reload schema'; - --- ─────────────────────────────────────────── --- 020_order_customer_linking.sql --- ─────────────────────────────────────────── --- ============================================================================= --- V1.2 Stage 2 — Order/Contact/Customer Linking --- Fully idempotent: CREATE OR REPLACE FUNCTION, no destructive operations --- Purely additive behavior: no changes to orders schema, no FKs, no backfill --- --- DEPENDENCY: Requires 019_customers_table.sql to be applied first. --- Raises an exception if the customers table does not exist. --- --- TRANSITIONAL ARCHITECTURE NOTE: --- orders.customer_id and customers.id are separate ID namespaces today. --- communication_contacts.customer_id links to customers.id for new order-created --- contacts (via this migration), but orders.customer_id is NOT yet aligned. --- --- A future stage (Stage 3+) should consider: --- - backfilling customers from existing orders/communication_contacts --- - aligning orders.customer_id to canonical customers.id --- - adding proper FK constraints only after data is cleaned --- - avoiding permanent dual customer ID namespaces --- --- STAGE 2 NORMALIZATION: --- email: trim(lower(...)) before match/upsert --- phone: regexp_replace(... '[\s\-().[\]]' '' 'g') before match/upsert --- This is consistent with frontend normalizeEmail/normalizePhone. --- ============================================================================= - --- ── Dependency guard ───────────────────────────────────────────────────────── -DO $$ -BEGIN - IF to_regclass('public.customers') IS NULL THEN - RAISE EXCEPTION 'Migration 019_customers_table.sql must be applied before 020_order_customer_linking.sql'; - END IF; -END; -$$; - -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- ── Safe teardown order: trigger → functions ──────────────────────────────── --- PostgreSQL requires dropping a trigger before dropping the function it uses. --- Order: DROP TRIGGER → DROP FUNCTION (helper) → DROP FUNCTION (trigger) - -DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders; -DROP FUNCTION IF EXISTS public.upsert_customer_from_order(UUID, TEXT, TEXT, TEXT, TEXT); -DROP FUNCTION IF EXISTS public.upsert_contact_from_order(); - --- ───────────────────────────────────────────────────────────────────────────── --- upsert_customer_from_order --- --- Resolves or creates a canonical customers record for a given order. --- Returns the customers.id for linking, or NULL if no contact method exists. --- --- Upsert order: email first → phone fallback (email is more stable identity) --- Brand separation: brand_id is the scoping key throughout. --- ───────────────────────────────────────────────────────────────────────────── - -CREATE OR REPLACE FUNCTION public.upsert_customer_from_order( - p_brand_id UUID, - p_customer_email TEXT, - p_customer_phone TEXT, - p_customer_name TEXT, - p_source TEXT DEFAULT 'order' -) -RETURNS UUID -- customers.id -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_norm_email TEXT := trim(lower(p_customer_email)); - v_norm_phone TEXT := regexp_replace(p_customer_phone, '[\s\-().[\]]', '', 'g'); - v_cust_id UUID; -BEGIN - -- No email AND no phone: nothing to upsert - IF (v_norm_email IS NULL OR v_norm_email = '') AND - (v_norm_phone IS NULL OR v_norm_phone = '') THEN - RETURN NULL; - END IF; - - -- Try email match first (within brand) - IF v_norm_email IS NOT NULL AND v_norm_email != '' THEN - SELECT id INTO v_cust_id - FROM public.customers - WHERE brand_id = p_brand_id - AND primary_email = v_norm_email; - - IF FOUND THEN - UPDATE public.customers SET - primary_phone = COALESCE(NULLIF(v_norm_phone, ''), primary_phone), - first_name = COALESCE( - NULLIF(split_part(p_customer_name, ' ', 1), ''), - customers.first_name - ), - last_name = COALESCE( - NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1 - for length(p_customer_name)), ''), - customers.last_name - ), - source = CASE - WHEN customers.source = 'system' THEN p_source ELSE customers.source - END, - updated_at = now() - WHERE id = v_cust_id; - RETURN v_cust_id; - END IF; - END IF; - - -- Phone fallback (only if email didn't find a match) - IF v_cust_id IS NULL AND v_norm_phone IS NOT NULL AND v_norm_phone != '' THEN - SELECT id INTO v_cust_id - FROM public.customers - WHERE brand_id = p_brand_id - AND primary_phone = v_norm_phone; - - IF FOUND THEN - UPDATE public.customers SET - primary_email = COALESCE(NULLIF(v_norm_email, ''), primary_email), - first_name = COALESCE( - NULLIF(split_part(p_customer_name, ' ', 1), ''), - customers.first_name - ), - last_name = COALESCE( - NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1 - for length(p_customer_name)), ''), - customers.last_name - ), - source = CASE - WHEN customers.source = 'system' THEN p_source ELSE customers.source - END, - updated_at = now() - WHERE id = v_cust_id; - RETURN v_cust_id; - END IF; - END IF; - - -- Insert new customer - INSERT INTO public.customers - (brand_id, primary_email, primary_phone, first_name, last_name, source) - VALUES ( - p_brand_id, - NULLIF(v_norm_email, ''), - NULLIF(v_norm_phone, ''), - NULLIF(split_part(p_customer_name, ' ', 1), ''), - NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1 - for length(p_customer_name)), ''), - p_source - ) - RETURNING id INTO v_cust_id; - - RETURN v_cust_id; -END; -$$; - --- ───────────────────────────────────────────────────────────────────────────── --- upsert_contact_from_order trigger --- --- NOW DOES TWO THINGS: --- 1. Call upsert_customer_from_order() to get/create customers.id --- 2. Use that customers.id as communication_contacts.customer_id (linking) --- --- OPT-OUT PRESERVATION: email_opt_in, sms_opt_in, unsubscribed_at are NOT --- updated on conflict — this behavior is unchanged from V1.1. --- ───────────────────────────────────────────────────────────────────────────── - -CREATE OR REPLACE FUNCTION public.upsert_contact_from_order() -RETURNS TRIGGER -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_brand_id UUID; - v_customer_id UUID; -BEGIN - -- Resolve brand_id from the stop - SELECT brand_id INTO v_brand_id - FROM public.stops - WHERE id = NEW.stop_id; - IF NOT FOUND THEN - RETURN NEW; - END IF; - - -- Step 1: upsert canonical customer (NEW.customer_id not referenced — - -- orders table does not have that column; use only email/phone/name) - v_customer_id := upsert_customer_from_order( - v_brand_id, - NEW.customer_email, - NEW.customer_phone, - NEW.customer_name, - 'order' - ); - - -- Step 2: upsert communication contact, linked to customers.id - INSERT INTO public.communication_contacts - (brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata) - VALUES ( - v_brand_id, - NEW.customer_email, - NEW.customer_phone, - NEW.customer_name, - 'order', - v_customer_id, - true, - jsonb_build_object( - 'last_order_id', NEW.id, - 'last_order_at', now()::TEXT, - 'stop_id', NEW.stop_id::TEXT - ) - ) - ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET - full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name), - phone = COALESCE(EXCLUDED.phone, communication_contacts.phone), - customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id), - metadata = jsonb_build_object( - 'last_order_id', communication_contacts.metadata->>'last_order_id', - 'last_order_at', communication_contacts.metadata->>'last_order_at', - 'stop_id', NEW.stop_id::TEXT - ), - updated_at = now(); - -- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out) - - RETURN NEW; -END; -$$; - --- ── Recreate trigger ───────────────────────────────────────────────────────── -CREATE TRIGGER trg_create_contact_from_order - AFTER INSERT ON public.orders - FOR EACH ROW - EXECUTE FUNCTION public.upsert_contact_from_order(); - -NOTIFY pgrst, 'reload schema'; - --- ─────────────────────────────────────────── --- 021_shipping_only_brand.sql --- ─────────────────────────────────────────── --- ============================================================================= --- V1.2 Stage 3 — Shipping-Only Brand Resolution --- Fully idempotent: CREATE OR REPLACE FUNCTION, ALTER TABLE ADD COLUMN IF NOT EXISTS --- --- DEPENDENCY: Requires 020_order_customer_linking.sql to be applied first. --- Raises an exception if the trigger function does not exist. --- --- CHANGES: --- 1. Add brand_id column to orders (nullable, no FK — aligned to products.brand_id) --- 2. Modify create_order_with_items to accept NULL stop_id for shipping-only --- 3. Update upsert_contact_from_order trigger to resolve brand from NEW.brand_id --- first, falling back to stop lookup — supports both pickup and shipping orders --- ============================================================================= - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = 'public' - AND p.proname = 'upsert_contact_from_order' - ) THEN - RAISE EXCEPTION 'Migration 020_order_customer_linking.sql must be applied before 021_shipping_only_brand.sql'; - END IF; -END; -$$; - --- ── 1. Add brand_id to orders ──────────────────────────────────────────────── --- Allows the order/contact trigger to resolve brand without needing a stop_id. --- For shipping-only orders, brand is derived from the first product. - -ALTER TABLE orders ADD COLUMN IF NOT EXISTS brand_id UUID; - --- ── 2. Modify create_order_with_items for shipping-only ──────────────────── --- stop_id is now nullable. When NULL (shipping-only), brand is derived from --- the first product's brand and no stop validation occurs. - -CREATE OR REPLACE FUNCTION create_order_with_items( - p_idempotency_key UUID, - p_customer_name TEXT, - p_customer_email TEXT, - p_customer_phone TEXT, - p_stop_id UUID, -- nullable for shipping-only - p_items JSONB -- [{id: uuid, quantity: int, fulfillment: text}] -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order_id UUID; - v_item JSONB; - v_product_id UUID; - v_quantity INT; - v_fulfillment TEXT; - v_product_price NUMERIC; - v_computed_total NUMERIC := 0; - v_stop_active BOOLEAN; - v_stop_brand_id UUID; - v_stop_city TEXT; - v_stop_state TEXT; - v_stop_date TEXT; - v_stop_time TEXT; - v_stop_location TEXT; - v_product_active BOOLEAN; - v_product_brand_id UUID; - v_product_name TEXT; - v_is_pickup BOOLEAN; - v_order JSONB; - v_order_items JSONB := '[]'::JSONB; - v_brand_id UUID; -BEGIN - -- ── Idempotency guard ───────────────────────────────────────────────────── - SELECT jsonb_build_object( - 'id', o.id, - 'customer_name', o.customer_name, - 'customer_email', o.customer_email, - 'customer_phone', o.customer_phone, - 'subtotal', o.subtotal, - 'status', o.status, - 'stop_id', o.stop_id, - 'brand_id', o.brand_id, - 'stop_city', s.city, - 'stop_state', s.state, - 'stop_date', s.date, - 'stop_time', s.time, - 'stop_location', s.location, - 'items', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'product_id', oi.product_id, - 'product_name', p.name, - 'quantity', oi.quantity, - 'price', oi.price, - 'fulfillment', oi.fulfillment - )) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id - ), '[]'::JSONB) - ) - INTO v_order - FROM orders o - LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.idempotency_key = p_idempotency_key - LIMIT 1; - - IF v_order IS NOT NULL THEN - RETURN v_order; - END IF; - - -- ── Resolve brand_id ────────────────────────────────────────────────────── - -- For shipping-only orders (p_stop_id IS NULL), derive from the first product. - -- For pickup orders, derive from the stop. - IF p_stop_id IS NOT NULL THEN - SELECT active, brand_id, city, state, date, time, location - INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location - FROM stops - WHERE id = p_stop_id; - - IF v_stop_brand_id IS NULL THEN - RAISE EXCEPTION 'Stop not found: %', p_stop_id; - END IF; - - IF NOT v_stop_active THEN - RAISE EXCEPTION 'Stop is not active: %', p_stop_id; - END IF; - - v_brand_id := v_stop_brand_id; - ELSE - -- Shipping-only: resolve brand from first product in the order - v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID; - - SELECT brand_id INTO v_brand_id - FROM products - WHERE id = v_product_id; - - IF v_brand_id IS NULL THEN - RAISE EXCEPTION 'Product not found: %', v_product_id; - END IF; - END IF; - - -- ── Validate items and compute subtotal ───────────────────────────────── - FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) - LOOP - v_product_id := (v_item->>'id')::UUID; - v_quantity := (v_item->>'quantity')::INT; - v_fulfillment := v_item->>'fulfillment'; - v_is_pickup := (v_fulfillment = 'pickup'); - - SELECT active, brand_id, price, name - INTO v_product_active, v_product_brand_id, v_product_price, v_product_name - FROM products - WHERE id = v_product_id; - - IF v_product_brand_id IS NULL THEN - RAISE EXCEPTION 'Product not found: %', v_product_id; - END IF; - - IF v_product_brand_id != v_brand_id THEN - RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id; - END IF; - - IF NOT v_product_active THEN - RAISE EXCEPTION 'Product is not active: %', v_product_id; - END IF; - - IF v_is_pickup THEN - -- Pickup items require a valid stop assignment - IF p_stop_id IS NULL THEN - RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id; - END IF; - - PERFORM 1 - FROM product_stops - WHERE product_id = v_product_id AND stop_id = p_stop_id - LIMIT 1; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id; - END IF; - END IF; - - v_computed_total := v_computed_total + (v_product_price * v_quantity); - END LOOP; - - -- ── Create order (with brand_id) ───────────────────────────────────────── - INSERT INTO orders ( - idempotency_key, customer_name, customer_email, customer_phone, - stop_id, brand_id, subtotal, status - ) VALUES ( - p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone, - p_stop_id, v_brand_id, v_computed_total, 'pending' - ) - RETURNING id INTO v_order_id; - - -- ── Insert order items and build return value ───────────────────────────── - FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) - LOOP - v_product_id := (v_item->>'id')::UUID; - v_quantity := (v_item->>'quantity')::INT; - v_fulfillment := v_item->>'fulfillment'; - - SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id; - - INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price) - VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price); - - v_order_items := v_order_items || jsonb_build_object( - 'product_id', v_product_id, - 'product_name', v_product_name, - 'quantity', v_quantity, - 'price', v_product_price, - 'fulfillment', v_fulfillment - ); - END LOOP; - - -- ── Build and return full order object ─────────────────────────────────── - RETURN jsonb_build_object( - 'id', v_order_id, - 'customer_name', p_customer_name, - 'customer_email', p_customer_email, - 'customer_phone', p_customer_phone, - 'subtotal', v_computed_total, - 'status', 'pending', - 'stop_id', p_stop_id, - 'brand_id', v_brand_id, - 'stop_city', v_stop_city, - 'stop_state', v_stop_state, - 'stop_date', v_stop_date, - 'stop_time', v_stop_time, - 'stop_location', v_stop_location, - 'items', v_order_items - ); -END; -$$; - --- ── 3. Update trigger to resolve brand from NEW.brand_id first ─────────────── --- NEW.brand_id is the primary source (always set by create_order_with_items). --- Stop lookup is the fallback (for pre-existing orders that lack brand_id). - -DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders; -DROP FUNCTION IF EXISTS public.upsert_contact_from_order(); - -CREATE OR REPLACE FUNCTION public.upsert_contact_from_order() -RETURNS TRIGGER -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_brand_id UUID; - v_customer_id UUID; -BEGIN - -- Primary: resolve brand_id from the order itself (NEW.brand_id, always set in Stage 3+) - IF NEW.brand_id IS NOT NULL THEN - v_brand_id := NEW.brand_id; - ELSE - -- Fallback: resolve via stop (for pre-Stage 3 orders without brand_id) - SELECT brand_id INTO v_brand_id - FROM public.stops - WHERE id = NEW.stop_id; - IF NOT FOUND THEN - RETURN NEW; -- cannot resolve brand: skip customer/contact linking - END IF; - END IF; - - -- No email AND no phone: nothing to upsert - IF (NEW.customer_email IS NULL OR NEW.customer_email = '') AND - (NEW.customer_phone IS NULL OR NEW.customer_phone = '') THEN - RETURN NEW; - END IF; - - -- Step 1: upsert canonical customer - v_customer_id := upsert_customer_from_order( - v_brand_id, - NEW.customer_email, - NEW.customer_phone, - NEW.customer_name, - 'order' - ); - - -- Step 2: upsert communication contact, linked to customers.id - INSERT INTO public.communication_contacts - (brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata) - VALUES ( - v_brand_id, - NEW.customer_email, - NEW.customer_phone, - NEW.customer_name, - 'order', - v_customer_id, - true, - jsonb_build_object( - 'last_order_id', NEW.id, - 'last_order_at', now()::TEXT, - 'stop_id', NEW.stop_id::TEXT - ) - ) - ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET - full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name), - phone = COALESCE(EXCLUDED.phone, communication_contacts.phone), - customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id), - metadata = jsonb_build_object( - 'last_order_id', communication_contacts.metadata->>'last_order_id', - 'last_order_at', communication_contacts.metadata->>'last_order_at', - 'stop_id', NEW.stop_id::TEXT - ), - updated_at = now(); - -- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out) - - RETURN NEW; -END; -$$; - -CREATE TRIGGER trg_create_contact_from_order - AFTER INSERT ON public.orders - FOR EACH ROW - EXECUTE FUNCTION public.upsert_contact_from_order(); - -NOTIFY pgrst, 'reload schema'; - --- ─────────────────────────────────────────── --- 022_operational_events.sql --- ─────────────────────────────────────────── --- ───────────────────────────────────────────────────────────────────────────── --- Migration 022: Operational Events --- ───────────────────────────────────────────────────────────────────────────── --- Append-only event layer for recording important platform actions. --- Scope: table + indexes + RLS + helpers + 4 initial emitters. --- What NOT included: automation, queues, webhooks, retries, cron jobs. - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. operational_events table --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS public.operational_events ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - event_type TEXT NOT NULL, - entity_type TEXT, - entity_id UUID, - actor_type TEXT, - actor_id UUID, - source TEXT NOT NULL DEFAULT 'system', - payload JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - CONSTRAINT operational_events_event_type CHECK ( - event_type ~ '^[a-z][a-z0-9_]*$' - ) -); - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. Indexes --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE INDEX IF NOT EXISTS idx_oe_brand_id ON operational_events(brand_id); -CREATE INDEX IF NOT EXISTS idx_oe_event_type ON operational_events(event_type); -CREATE INDEX IF NOT EXISTS idx_oe_entity ON operational_events(entity_type, entity_id); -CREATE INDEX IF NOT EXISTS idx_oe_created_at ON operational_events(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_oe_brand_type ON operational_events(brand_id, event_type, created_at DESC); - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. RLS policies --- ═══════════════════════════════════════════════════════════════════════════ - -ALTER TABLE operational_events ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Brand admin can read operational_events" ON operational_events; -CREATE POLICY "Brand admin can read operational_events" - ON operational_events FOR SELECT TO authenticated - USING ( - brand_id IN ( - SELECT brand_id FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'brand_admin' - ) - ); - -DROP POLICY IF EXISTS "Platform admin can read operational_events" ON operational_events; -CREATE POLICY "Platform admin can read operational_events" - ON operational_events FOR SELECT TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - ) - ); - --- Writes go through SECURITY DEFINER helpers only. --- No INSERT granted to authenticated roles. - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. record_operational_event helper — bypasses RLS, all emitters call this --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.record_operational_event( - p_brand_id UUID, - p_event_type TEXT, - p_entity_type TEXT, - p_entity_id UUID, - p_actor_type TEXT, - p_actor_id UUID, - p_source TEXT, - p_payload JSONB -) -RETURNS void -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO operational_events ( - brand_id, event_type, entity_type, entity_id, - actor_type, actor_id, source, payload - ) VALUES ( - p_brand_id, p_event_type, p_entity_type, p_entity_id, - p_actor_type, p_actor_id, p_source, p_payload - ); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 5. record_pickup_completed_event helper — called from TypeScript action --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.record_pickup_completed_event( - p_order_id UUID, - p_brand_id UUID, - p_actor_id UUID -) -RETURNS void -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order_data JSONB; -BEGIN - SELECT jsonb_build_object( - 'subtotal', subtotal, - 'customer_name', customer_name - ) INTO v_order_data - FROM orders - WHERE id = p_order_id; - - PERFORM record_operational_event( - p_brand_id, - 'pickup_completed', - 'order', - p_order_id, - 'admin', - p_actor_id, - 'system', - coalesce(v_order_data, '{}'::JSONB) - ); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 6. create_order_with_items — with order_placed emitter --- (explicit replace; same signature as migration 021) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION create_order_with_items( - p_idempotency_key UUID, - p_customer_name TEXT, - p_customer_email TEXT, - p_customer_phone TEXT, - p_stop_id UUID, - p_items JSONB -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_order_id UUID; - v_item JSONB; - v_product_id UUID; - v_quantity INT; - v_fulfillment TEXT; - v_product_price NUMERIC; - v_computed_total NUMERIC := 0; - v_stop_active BOOLEAN; - v_stop_brand_id UUID; - v_stop_city TEXT; - v_stop_state TEXT; - v_stop_date TEXT; - v_stop_time TEXT; - v_stop_location TEXT; - v_product_active BOOLEAN; - v_product_brand_id UUID; - v_product_name TEXT; - v_is_pickup BOOLEAN; - v_has_pickup BOOLEAN := false; - v_order JSONB; - v_order_items JSONB := '[]'::JSONB; - v_brand_id UUID; -BEGIN - -- ── Idempotency guard ───────────────────────────────────────────────────── - SELECT jsonb_build_object( - 'id', o.id, - 'customer_name', o.customer_name, - 'customer_email', o.customer_email, - 'customer_phone', o.customer_phone, - 'subtotal', o.subtotal, - 'status', o.status, - 'stop_id', o.stop_id, - 'brand_id', o.brand_id, - 'stop_city', s.city, - 'stop_state', s.state, - 'stop_date', s.date, - 'stop_time', s.time, - 'stop_location', s.location, - 'items', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'product_id', oi.product_id, - 'product_name', p.name, - 'quantity', oi.quantity, - 'price', oi.price, - 'fulfillment', oi.fulfillment - )) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id - ), '[]'::JSONB) - ) - INTO v_order - FROM orders o - LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.idempotency_key = p_idempotency_key - LIMIT 1; - - IF v_order IS NOT NULL THEN - RETURN v_order; - END IF; - - -- ── Resolve brand_id ────────────────────────────────────────────────────── - IF p_stop_id IS NOT NULL THEN - SELECT active, brand_id, city, state, date, time, location - INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location - FROM stops - WHERE id = p_stop_id; - - IF v_stop_brand_id IS NULL THEN - RAISE EXCEPTION 'Stop not found: %', p_stop_id; - END IF; - - IF NOT v_stop_active THEN - RAISE EXCEPTION 'Stop is not active: %', p_stop_id; - END IF; - - v_brand_id := v_stop_brand_id; - ELSE - v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID; - - SELECT brand_id INTO v_brand_id - FROM products - WHERE id = v_product_id; - - IF v_brand_id IS NULL THEN - RAISE EXCEPTION 'Product not found: %', v_product_id; - END IF; - END IF; - - -- ── Validate items and compute subtotal ───────────────────────────────── - FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) - LOOP - v_product_id := (v_item->>'id')::UUID; - v_quantity := (v_item->>'quantity')::INT; - v_fulfillment := v_item->>'fulfillment'; - v_is_pickup := (v_fulfillment = 'pickup'); - - IF v_is_pickup THEN - v_has_pickup := true; - END IF; - - SELECT active, brand_id, price, name - INTO v_product_active, v_product_brand_id, v_product_price, v_product_name - FROM products - WHERE id = v_product_id; - - IF v_product_brand_id IS NULL THEN - RAISE EXCEPTION 'Product not found: %', v_product_id; - END IF; - - IF v_product_brand_id != v_brand_id THEN - RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id; - END IF; - - IF NOT v_product_active THEN - RAISE EXCEPTION 'Product is not active: %', v_product_id; - END IF; - - IF v_is_pickup THEN - IF p_stop_id IS NULL THEN - RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id; - END IF; - - PERFORM 1 - FROM product_stops - WHERE product_id = v_product_id AND stop_id = p_stop_id - LIMIT 1; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id; - END IF; - END IF; - - v_computed_total := v_computed_total + (v_product_price * v_quantity); - END LOOP; - - -- ── Create order (with brand_id) ───────────────────────────────────────── - INSERT INTO orders ( - idempotency_key, customer_name, customer_email, customer_phone, - stop_id, brand_id, subtotal, status - ) VALUES ( - p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone, - p_stop_id, v_brand_id, v_computed_total, 'pending' - ) - RETURNING id INTO v_order_id; - - -- ── Insert order items and build return value ───────────────────────────── - FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) - LOOP - v_product_id := (v_item->>'id')::UUID; - v_quantity := (v_item->>'quantity')::INT; - v_fulfillment := v_item->>'fulfillment'; - - SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id; - - INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price) - VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price); - - v_order_items := v_order_items || jsonb_build_object( - 'product_id', v_product_id, - 'product_name', v_product_name, - 'quantity', v_quantity, - 'price', v_product_price, - 'fulfillment', v_fulfillment - ); - END LOOP; - - -- ── Emit order_placed event ─────────────────────────────────────────────── - PERFORM record_operational_event( - v_brand_id, - 'order_placed', - 'order', - v_order_id, - 'customer', - NULL, - 'system', - jsonb_build_object( - 'subtotal', v_computed_total, - 'item_count', jsonb_array_length(p_items), - 'has_pickup', v_has_pickup - ) - ); - - -- ── Build and return full order object ─────────────────────────────────── - RETURN jsonb_build_object( - 'id', v_order_id, - 'customer_name', p_customer_name, - 'customer_email', p_customer_email, - 'customer_phone', p_customer_phone, - 'subtotal', v_computed_total, - 'status', 'pending', - 'stop_id', p_stop_id, - 'brand_id', v_brand_id, - 'stop_city', v_stop_city, - 'stop_state', v_stop_state, - 'stop_date', v_stop_date, - 'stop_time', v_stop_time, - 'stop_location', v_stop_location, - 'items', v_order_items - ); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 7. import_communication_contacts_batch — with contact_imported emitter --- (explicit replace; same signature as migrations 017/018) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch( - p_brand_id UUID, - p_contacts JSONB, - p_allow_opt_in_override BOOLEAN DEFAULT false -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_entry JSONB; - v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB; - v_existing communication_contacts%ROWTYPE; - v_email TEXT; - v_phone TEXT; - v_tags TEXT[]; -BEGIN - FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts) - LOOP - v_email := nullif(v_entry->>'email', ''); - v_phone := nullif(v_entry->>'phone', ''); - - IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN - v_tags := coalesce( - (SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''), - '{}' - ); - ELSE - v_tags := '{}'; - END IF; - - BEGIN - IF v_email IS NOT NULL AND v_email != '' THEN - SELECT * INTO v_existing - FROM public.communication_contacts - WHERE brand_id = p_brand_id AND email = v_email; - - IF FOUND THEN - IF v_existing.unsubscribed_at IS NOT NULL AND - coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND - p_allow_opt_in_override = false THEN - v_result := jsonb_set(v_result, '{skipped}', - to_jsonb((v_result->>'skipped')::INTEGER + 1)); - ELSE - UPDATE public.communication_contacts SET - phone = COALESCE(nullif(v_entry->>'phone', ''), phone), - first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name), - last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name), - full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name), - source = 'import', - external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id), - email_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.email_opt_in - ELSE coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) - END, - sms_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.sms_opt_in - ELSE coalesce( - (v_entry->>'sms_opt_in')::BOOLEAN, - communication_contacts.sms_opt_in, - false - ) - END, - email_opt_in_at = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN email_opt_in_at - WHEN coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) = true THEN now() - ELSE email_opt_in_at - END, - tags = CASE - WHEN v_tags != '{}' THEN v_tags - ELSE communication_contacts.tags - END, - metadata = communication_contacts.metadata || jsonb_build_object( - 'imported_at', now()::TEXT - ), - updated_at = now() - WHERE brand_id = p_brand_id AND email = v_email; - - v_result := jsonb_set(v_result, '{updated}', - to_jsonb((v_result->>'updated')::INTEGER + 1)); - END IF; - - ELSE - INSERT INTO public.communication_contacts - (brand_id, email, phone, first_name, last_name, full_name, source, - external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at, - tags, metadata) - VALUES ( - p_brand_id, - v_email, - nullif(v_entry->>'phone', ''), - nullif(v_entry->>'first_name', ''), - nullif(v_entry->>'last_name', ''), - nullif(v_entry->>'full_name', ''), - 'import', - nullif(v_entry->>'external_id', ''), - coalesce((v_entry->>'email_opt_in')::BOOLEAN, true), - coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false), - CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END, - CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END, - v_tags, - jsonb_build_object('imported_at', now()::TEXT) - ); - v_result := jsonb_set(v_result, '{created}', - to_jsonb((v_result->>'created')::INTEGER + 1)); - END IF; - - ELSIF v_phone IS NOT NULL AND v_phone != '' THEN - SELECT * INTO v_existing - FROM public.communication_contacts - WHERE brand_id = p_brand_id AND phone = v_phone; - - IF FOUND THEN - IF v_existing.unsubscribed_at IS NOT NULL AND - coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND - p_allow_opt_in_override = false THEN - v_result := jsonb_set(v_result, '{skipped}', - to_jsonb((v_result->>'skipped')::INTEGER + 1)); - ELSE - UPDATE public.communication_contacts SET - email = COALESCE(nullif(v_entry->>'email', ''), email), - first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name), - last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name), - full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name), - source = 'import', - email_opt_in = CASE - WHEN communication_contacts.unsubscribed_at IS NOT NULL - THEN communication_contacts.email_opt_in - ELSE coalesce( - (v_entry->>'email_opt_in')::BOOLEAN, - communication_contacts.email_opt_in, - true - ) - END, - tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END, - metadata = communication_contacts.metadata || jsonb_build_object( - 'imported_at', now()::TEXT - ), - updated_at = now() - WHERE brand_id = p_brand_id AND phone = v_phone; - - v_result := jsonb_set(v_result, '{updated}', - to_jsonb((v_result->>'updated')::INTEGER + 1)); - END IF; - ELSE - INSERT INTO public.communication_contacts - (brand_id, email, phone, first_name, last_name, full_name, source, - email_opt_in, sms_opt_in, tags, metadata) - VALUES ( - p_brand_id, - nullif(v_entry->>'email', ''), - v_phone, - nullif(v_entry->>'first_name', ''), - nullif(v_entry->>'last_name', ''), - nullif(v_entry->>'full_name', ''), - 'import', - coalesce((v_entry->>'email_opt_in')::BOOLEAN, true), - coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false), - v_tags, - jsonb_build_object('imported_at', now()::TEXT) - ); - v_result := jsonb_set(v_result, '{created}', - to_jsonb((v_result->>'created')::INTEGER + 1)); - END IF; - - ELSE - v_result := jsonb_set( - v_result, '{errors}', - v_result->'errors' || jsonb_build_array( - jsonb_build_object('row', v_entry, 'error', 'No email or phone provided') - ) - ); - END IF; - - EXCEPTION WHEN OTHERS THEN - v_result := jsonb_set( - v_result, '{errors}', - v_result->'errors' || jsonb_build_array( - jsonb_build_object('row', v_entry, 'error', SQLERRM) - ) - ); - END; - END LOOP; - - -- ── Emit contact_imported event ────────────────────────────────────────── - PERFORM record_operational_event( - p_brand_id, - 'contact_imported', - NULL, - NULL, - 'admin', - NULL, - 'system', - jsonb_build_object( - 'created', (v_result->>'created')::INTEGER, - 'updated', (v_result->>'updated')::INTEGER, - 'skipped', (v_result->>'skipped')::INTEGER, - 'total', - (v_result->>'created')::INTEGER - + (v_result->>'updated')::INTEGER - + (v_result->>'skipped')::INTEGER - ) - ); - - RETURN v_result; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 8. send_campaign — with campaign_sent emitter --- (explicit replace; same body as migration 017) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID) -RETURNS jsonb -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_campaign communication_campaigns; - v_settings communication_settings; - v_audience JSONB; - v_entry JSONB; - v_entries JSONB := '[]'::JSONB; - v_count INTEGER := 0; -BEGIN - SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Campaign not found'); - END IF; - - SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id; - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand'); - END IF; - - v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules); - - FOR v_entry IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb)) - LOOP - CONTINUE WHEN (v_entry->>'email') IS NULL OR (v_entry->>'email') = ''; - - v_entries := v_entries || jsonb_build_array(jsonb_build_object( - 'brand_id', v_campaign.brand_id, - 'campaign_id', p_campaign_id, - 'customer_id', nullif(v_entry->>'id', ''), - 'customer_email', v_entry->>'email', - 'delivery_method','email', - 'subject', v_campaign.subject, - 'body_preview', left(v_campaign.body_text, 500), - 'status', 'queued' - )); - v_count := v_count + 1; - END LOOP; - - PERFORM log_communication_messages(v_entries); - - UPDATE communication_campaigns - SET status = 'sent', sent_at = now(), updated_at = now() - WHERE id = p_campaign_id; - - -- ── Emit campaign_sent event ──────────────────────────────────────────── - PERFORM record_operational_event( - v_campaign.brand_id, - 'campaign_sent', - 'campaign', - p_campaign_id, - 'system', - NULL, - 'system', - jsonb_build_object( - 'messages_logged', v_count, - 'subject', v_campaign.subject - ) - ); - - RETURN jsonb_build_object('success', true, 'messages_logged', v_count); -END; -$$; --- ─────────────────────────────────────────── --- 023_cart_availability_check.sql --- ─────────────────────────────────────────── --- Migration 023: Fix cart availability check --- Replaces unreliable client-side product_stops query with a --- SECURITY DEFINER RPC that bypasses RLS and returns structured availability. --- --- The cart page's availability check was: --- 1. Unreliable — anon/frontend query may be blocked by RLS or return empty --- 2. Conflating "no rows" with "product is unavailable" --- 3. Blocking all stops even when the query itself failed --- --- This adds: check_stop_product_availability(p_stop_id, p_product_ids) --- Returns: { product_id, is_available }[] for each requested product. --- The cart page uses this to show truly incompatible items separately --- from query errors. - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. check_stop_product_availability RPC --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.check_stop_product_availability( - p_stop_id UUID, - p_product_ids UUID[] -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB := '[]'::JSONB; - v_pid UUID; -BEGIN - FOR v_pid IN SELECT unnest(p_product_ids) - LOOP - v_result := v_result || jsonb_build_array(jsonb_build_object( - 'product_id', v_pid, - 'is_available', EXISTS( - SELECT 1 FROM product_stops - WHERE stop_id = p_stop_id AND product_id = v_pid - ) - )); - END LOOP; - - RETURN v_result; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. Update cart/page.tsx to use the RPC + show query errors distinctly --- (done in the application code, not the migration) --- ═══════════════════════════════════════════════════════════════════════════ --- --- Changes to src/app/cart/page.tsx: --- - handleStopSelect: POST to check_stop_product_availability RPC instead of --- direct product_stops query. Handle errors distinctly from unavailability. --- - Add availabilityError state — if RPC fails, show "Unable to verify --- availability" but allow checkout to proceed (server will catch true errors). --- - Add per-product availabilityError flag to distinguish query failure --- from confirmed unavailability. --- ─────────────────────────────────────────── --- 024_stop_product_assignment_rpcs.sql --- ─────────────────────────────────────────── --- Migration 024: Stop-product assignment via SECURITY DEFINER RPCs --- Replaces direct INSERT/DELETE on product_stops (blocked by RLS) --- with admin-authorized RPCs that validate brand alignment. - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. assign_product_to_stop --- Idempotent: if row already exists, returns the existing row. --- Validates: stop exists, product exists, brands match. --- Authorizes: platform_admin (all brands) or brand_admin (own brand only). --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.assign_product_to_stop( - p_stop_id UUID, - p_product_id UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_existing UUID; - v_result JSONB; -BEGIN - -- Verify stop exists and get its brand - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Verify product exists and get its brand - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Brand alignment check - IF v_stop_brand_id != v_product_brand_id THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed' - ); - END IF; - - -- Authorization: must be platform_admin or brand_admin for this brand - IF NOT EXISTS ( - SELECT 1 FROM admin_users - WHERE user_id = auth.uid() - AND (role = 'platform_admin' OR (role = 'brand_admin' AND brand_id = v_stop_brand_id)) - ) THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to assign products to this stop'); - END IF; - - -- Idempotent insert: check if row already exists - SELECT id INTO v_existing - FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - IF v_existing IS NOT NULL THEN - RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true); - END IF; - - -- Insert new row - INSERT INTO product_stops (stop_id, product_id) - VALUES (p_stop_id, p_product_id) - RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id) - INTO v_result; - - RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. unassign_product_from_stop --- Deletes the product_stop row. Safe — no side effects if row doesn't exist. --- Authorizes: platform_admin (all brands) or brand_admin (own brand only). --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.unassign_product_from_stop( - p_stop_id UUID, - p_product_id UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; -BEGIN - -- Verify stop exists and get its brand - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Authorization: must be platform_admin or brand_admin for this brand - IF NOT EXISTS ( - SELECT 1 FROM admin_users - WHERE user_id = auth.uid() - AND (role = 'platform_admin' OR (role = 'brand_admin' AND brand_id = v_stop_brand_id)) - ) THEN - RETURN jsonb_build_object('success', false, 'error', 'Not authorized to unassign products from this stop'); - END IF; - - DELETE FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - RETURN jsonb_build_object('success', true, 'deleted', true); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. get_stop_products --- Returns all products assigned to a stop, with product details. --- Used by admin UI to refresh the list after assign/unassign. --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN jsonb_build_object('products', ( - SELECT COALESCE(jsonb_agg( - jsonb_build_object( - 'id', ps.id, - 'product_id', ps.product_id, - 'name', p.name, - 'type', p.type, - 'price', p.price - ) - ), '[]'::JSONB) - FROM product_stops ps - JOIN products p ON p.id = ps.product_id - WHERE ps.stop_id = p_stop_id - )); -END; -$$; --- ─────────────────────────────────────────── --- 025_fix_assignment_rpc_auth.sql --- ─────────────────────────────────────────── --- Migration 025: Fix admin assignment RPC authorization --- --- Problem: auth.uid() is NULL when RPC is called via REST (anon key). --- SECURITY DEFINER runs as postgres, but auth.uid() reflects the session user. --- A direct REST call has no authenticated session → auth.uid() = NULL. --- --- Fix: accept p_caller_uid as an explicit parameter from the admin UI. --- The UI already knows the current user from getAdminUser(). --- The RPC validates authorization by looking up admin_users with p_caller_uid. - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. assign_product_to_stop (corrected auth) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.assign_product_to_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid UUID -- explicitly passed by the admin UI -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_existing UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_result JSONB; -BEGIN - -- Verify stop exists and get its brand - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Verify product exists and get its brand - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Brand alignment check - IF v_stop_brand_id != v_product_brand_id THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed' - ); - END IF; - - -- Look up the admin user with the explicitly-passed caller UID - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as an admin user'); - END IF; - - -- Authorization: platform_admin can manage any stop; brand_admin only their brand - IF v_admin_role = 'platform_admin' THEN - -- platform_admin: allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- brand_admin for this brand: allowed - ELSE - RETURN jsonb_build_object( - 'success', false, - 'error', 'Not authorized to assign products to this stop — requires platform_admin or brand_admin for this brand' - ); - END IF; - - -- Idempotent: if already assigned, return existing row - SELECT id INTO v_existing - FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - IF v_existing IS NOT NULL THEN - RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true); - END IF; - - -- Insert new row - INSERT INTO product_stops (stop_id, product_id) - VALUES (p_stop_id, p_product_id) - RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id) - INTO v_result; - - RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. unassign_product_from_stop (corrected auth) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.unassign_product_from_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; -BEGIN - -- Verify stop exists and get its brand - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Look up admin user - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as an admin user'); - END IF; - - -- Authorization - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object( - 'success', false, - 'error', 'Not authorized to unassign products from this stop' - ); - END IF; - - DELETE FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - RETURN jsonb_build_object('success', true, 'deleted', true); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. get_stop_products — no auth needed for product list (reads data only) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN jsonb_build_object('products', ( - SELECT COALESCE(jsonb_agg( - jsonb_build_object( - 'id', ps.id, - 'product_id', ps.product_id, - 'name', p.name, - 'type', p.type, - 'price', p.price - ) - ), '[]'::JSONB) - FROM product_stops ps - JOIN products p ON p.id = ps.product_id - WHERE ps.stop_id = p_stop_id - )); -END; -$$; --- ─────────────────────────────────────────── --- 026_debug_assignment_rpc.sql --- ─────────────────────────────────────────── --- Migration 026: Debug stop-product assignment --- Temporary diagnostic RPC to reveal exactly why assignment authorization fails. --- Remove this after the bug is fixed. - --- ═══════════════════════════════════════════════════════════════════════════ --- debug_stop_product_assignment --- Returns a detailed diagnostics object so we can see which check failed. --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_admin_found BOOLEAN := false; - v_stop_found BOOLEAN := false; - v_product_found BOOLEAN := false; - v_brand_match BOOLEAN; - v_authorized BOOLEAN := false; - v_reason TEXT := 'not checked'; -BEGIN - -- Check stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF FOUND THEN - v_stop_found := true; - ELSE - v_reason := 'stop not found'; - END IF; - - -- Check product - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF FOUND THEN - v_product_found := true; - ELSE - v_reason := 'product not found'; - END IF; - - -- Check admin_users - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid; - - IF FOUND THEN - v_admin_found := true; - ELSE - v_reason := 'admin user not found in admin_users table'; - END IF; - - -- Brand match - IF v_stop_found AND v_product_found THEN - v_brand_match := (v_stop_brand_id = v_product_brand_id); - IF NOT v_brand_match THEN - v_reason := 'brand mismatch between stop and product'; - END IF; - END IF; - - -- Authorization decision - IF v_admin_found AND v_stop_found AND v_product_found AND v_brand_match THEN - IF v_admin_role = 'platform_admin' THEN - v_authorized := true; - v_reason := 'authorized as platform_admin'; - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - v_authorized := true; - v_reason := 'authorized as brand_admin for this brand'; - ELSE - v_authorized := false; - v_reason := 'admin role "' || v_admin_role || '" with brand_id "' || - COALESCE(v_admin_brand_id::TEXT, 'NULL') || - '" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"'; - END IF; - END IF; - - RETURN jsonb_build_object( - 'caller_uid', p_caller_uid, - 'admin_found', v_admin_found, - 'admin_role', v_admin_role, - 'admin_brand_id', v_admin_brand_id, - 'stop_found', v_stop_found, - 'stop_brand_id', v_stop_brand_id, - 'product_found', v_product_found, - 'product_brand_id', v_product_brand_id, - 'brand_match', v_brand_match, - 'authorized', v_authorized, - 'reason', v_reason - ); -END; -$$; --- ─────────────────────────────────────────── --- 027_fix_rpc_signature_conflict.sql --- ─────────────────────────────────────────── --- Migration 027: Fix assignment RPC signature conflict --- --- Root cause: Migration 024 created assign_product_to_stop(p_stop_id, p_product_id) --- with 2 params. Migration 025 tried to CREATE OR REPLACE it with 3 params, --- but CREATE OR REPLACE cannot change parameter count — the replacement failed --- and the stale 2-param version remains in the schema. --- --- Frontend calls with 3 params (p_caller_uid), but PostgreSQL matches the --- 2-param overload and returns "function does not exist" (400) because the --- 3-param call has no match. --- --- Fix: --- 1. DROP the old 2-param overloads explicitly --- 2. CREATE the 3-param versions cleanly --- 3. Notify PostgREST schema cache to reload --- ═══════════════════════════════════════════════════════════════════════════ - --- Drop stale 2-param versions (migrations 024) -DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID); -DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID); - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. assign_product_to_stop (3-param, SECURITY DEFINER) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.assign_product_to_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_existing UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_result JSONB; -BEGIN - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Verify product - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Cross-brand check - IF v_stop_brand_id != v_product_brand_id THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Product brand does not match stop brand' - ); - END IF; - - -- Look up caller in admin_users - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization: platform_admin OR brand_admin for this brand - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object( - 'success', false, - 'error', 'Not authorized — requires platform_admin or brand_admin for this brand' - ); - END IF; - - -- Idempotent insert - SELECT id INTO v_existing - FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - IF v_existing IS NOT NULL THEN - RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true); - END IF; - - INSERT INTO product_stops (stop_id, product_id) - VALUES (p_stop_id, p_product_id) - RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id) - INTO v_result; - - RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. unassign_product_from_stop (3-param, SECURITY DEFINER) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.unassign_product_from_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid UUID -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; -BEGIN - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Look up caller - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object('success', false, 'error', 'Not authorized'); - END IF; - - DELETE FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - RETURN jsonb_build_object('success', true, 'deleted', true); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. get_stop_products (no auth, product list) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN jsonb_build_object('products', ( - SELECT COALESCE(jsonb_agg( - jsonb_build_object('id', ps.id, 'product_id', ps.product_id, - 'name', p.name, 'type', p.type, 'price', p.price) - ), '[]'::JSONB) - FROM product_stops ps - JOIN products p ON p.id = ps.product_id - WHERE ps.stop_id = p_stop_id - )); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. Refresh PostgREST schema cache so it picks up the new signatures --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; --- ─────────────────────────────────────────── --- 028_fix_caller_uid_type.sql --- ─────────────────────────────────────────── --- Migration 028: Fix dev-user UUID mismatch and clean up RPC signatures --- --- Root cause: p_caller_uid was defined as UUID, but the dev session --- (getAdminUser) returns user_id = 'dev-user-00000000-...' which is not --- a valid UUID. PostgreSQL rejects it with "invalid input syntax for type uuid". --- --- Fix: change p_caller_uid to TEXT — comparison with admin_users.user_id --- (UUID column) works fine since PostgreSQL casts TEXT to UUID implicitly. --- --- Also cleans up: drops stale 2-param overloads so PostgREST has no ambiguity. - --- ═══════════════════════════════════════════════════════════════════════════ --- Drop stale 2-param overloads from migrations 024/025 --- ═══════════════════════════════════════════════════════════════════════════ - -DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID); -DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID); - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. assign_product_to_stop (p_caller_uid is TEXT to accept dev-user format) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.assign_product_to_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -- TEXT to accept both UUIDs and "dev-user-..." strings -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_existing UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_result JSONB; -BEGIN - -- Verify stop exists - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Verify product exists - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Cross-brand guard - IF v_stop_brand_id != v_product_brand_id THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed' - ); - END IF; - - -- Look up admin by user_id (admin_users.user_id is UUID; TEXT cast works) - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid::UUID - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization: platform_admin OR brand_admin for this brand - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object( - 'success', false, - 'error', 'Not authorized — requires platform_admin or brand_admin for this brand' - ); - END IF; - - -- Idempotent insert - SELECT id INTO v_existing - FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - IF v_existing IS NOT NULL THEN - RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true); - END IF; - - INSERT INTO product_stops (stop_id, product_id) - VALUES (p_stop_id, p_product_id) - RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id) - INTO v_result; - - RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. unassign_product_from_stop (p_caller_uid is TEXT) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.unassign_product_from_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; -BEGIN - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Look up admin - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid::UUID - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object('success', false, 'error', 'Not authorized'); - END IF; - - DELETE FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - RETURN jsonb_build_object('success', true, 'deleted', true); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. debug_stop_product_assignment (p_caller_uid is TEXT) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_admin_found BOOLEAN := false; - v_stop_found BOOLEAN := false; - v_product_found BOOLEAN := false; - v_brand_match BOOLEAN; - v_authorized BOOLEAN := false; - v_reason TEXT := 'not checked'; -BEGIN - -- Check stop - SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id; - IF FOUND THEN v_stop_found := true; END IF; - - -- Check product - SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id; - IF FOUND THEN v_product_found := true; END IF; - - -- Check admin_users - BEGIN - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid::UUID; - IF FOUND THEN v_admin_found := true; END IF; - EXCEPTION WHEN OTHERS THEN - v_reason := 'admin lookup failed: ' || SQLERRM; - END; - - -- Brand match - IF v_stop_found AND v_product_found THEN - v_brand_match := (v_stop_brand_id = v_product_brand_id); - END IF; - - -- Authorization decision - IF v_admin_found AND v_stop_found AND v_product_found THEN - IF v_brand_match THEN - IF v_admin_role = 'platform_admin' THEN - v_authorized := true; v_reason := 'authorized as platform_admin'; - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - v_authorized := true; v_reason := 'authorized as brand_admin for this brand'; - ELSE - v_authorized := false; - v_reason := 'role "' || v_admin_role || '" with brand_id "' || - COALESCE(v_admin_brand_id::TEXT, 'NULL') || - '" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"'; - END IF; - ELSE - v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || - ', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL'); - END IF; - END IF; - - RETURN jsonb_build_object( - 'caller_uid', p_caller_uid, - 'admin_found', v_admin_found, - 'admin_role', v_admin_role, - 'admin_brand_id', v_admin_brand_id, - 'stop_found', v_stop_found, - 'stop_brand_id', v_stop_brand_id, - 'product_found', v_product_found, - 'product_brand_id', v_product_brand_id, - 'brand_match', v_brand_match, - 'authorized', v_authorized, - 'reason', v_reason - ); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. Refresh PostgREST schema cache --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; --- ─────────────────────────────────────────── --- 029_drop_stale_overloads.sql --- ─────────────────────────────────────────── --- Migration 029: Remove all stale overloads, keep only TEXT caller_uid versions --- --- Root cause: Migration 028 changed p_caller_uid to TEXT in the CREATE OR REPLACE --- body, but the 3-param UUID overload (from migrations 024/025/027) was never --- dropped. PostgreSQL now has TWO valid candidates: --- assign_product_to_stop(uuid, uuid, text) -- migration 028 --- assign_product_to_stop(uuid, uuid, uuid) -- migration 027 --- PostgREST cannot resolve which to call → "Could not choose the best candidate". --- --- Fix: DROP all old signatures explicitly, then CREATE ONLY the TEXT versions. --- ═══════════════════════════════════════════════════════════════════════════ - --- ═══════════════════════════════════════════════════════════════════════════ --- Drop ALL stale overloads for each function --- ═══════════════════════════════════════════════════════════════════════════ - --- assign_product_to_stop -DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID); -DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID, UUID); - --- unassign_product_from_stop -DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID); -DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID, UUID); - --- debug_stop_product_assignment -DROP FUNCTION IF EXISTS public.debug_stop_product_assignment(UUID, UUID, UUID); - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. assign_product_to_stop (TEXT caller_uid) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.assign_product_to_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_existing UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_result JSONB; -BEGIN - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Verify product - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Cross-brand guard - IF v_stop_brand_id != v_product_brand_id THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed' - ); - END IF; - - -- Look up admin by user_id (admin_users.user_id is UUID; TEXT cast works) - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid::UUID - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization: platform_admin OR brand_admin for this brand - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object( - 'success', false, - 'error', 'Not authorized — requires platform_admin or brand_admin for this brand' - ); - END IF; - - -- Idempotent insert - SELECT id INTO v_existing - FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - IF v_existing IS NOT NULL THEN - RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true); - END IF; - - INSERT INTO product_stops (stop_id, product_id) - VALUES (p_stop_id, p_product_id) - RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id) - INTO v_result; - - RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. unassign_product_from_stop (TEXT caller_uid) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.unassign_product_from_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; -BEGIN - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Look up admin - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid::UUID - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object('success', false, 'error', 'Not authorized'); - END IF; - - DELETE FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - RETURN jsonb_build_object('success', true, 'deleted', true); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. debug_stop_product_assignment (TEXT caller_uid) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_admin_found BOOLEAN := false; - v_stop_found BOOLEAN := false; - v_product_found BOOLEAN := false; - v_brand_match BOOLEAN; - v_authorized BOOLEAN := false; - v_reason TEXT := 'not checked'; -BEGIN - -- Check stop - SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id; - IF FOUND THEN v_stop_found := true; END IF; - - -- Check product - SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id; - IF FOUND THEN v_product_found := true; END IF; - - -- Check admin_users - BEGIN - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = p_caller_uid::UUID; - IF FOUND THEN v_admin_found := true; END IF; - EXCEPTION WHEN OTHERS THEN - v_reason := 'admin lookup failed: ' || SQLERRM; - END; - - -- Brand match - IF v_stop_found AND v_product_found THEN - v_brand_match := (v_stop_brand_id = v_product_brand_id); - END IF; - - -- Authorization decision - IF v_admin_found AND v_stop_found AND v_product_found THEN - IF v_brand_match THEN - IF v_admin_role = 'platform_admin' THEN - v_authorized := true; v_reason := 'authorized as platform_admin'; - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - v_authorized := true; v_reason := 'authorized as brand_admin for this brand'; - ELSE - v_authorized := false; - v_reason := 'role "' || v_admin_role || '" with brand_id "' || - COALESCE(v_admin_brand_id::TEXT, 'NULL') || - '" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"'; - END IF; - ELSE - v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || - ', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL'); - END IF; - END IF; - - RETURN jsonb_build_object( - 'caller_uid', p_caller_uid, - 'admin_found', v_admin_found, - 'admin_role', v_admin_role, - 'admin_brand_id', v_admin_brand_id, - 'stop_found', v_stop_found, - 'stop_brand_id', v_stop_brand_id, - 'product_found', v_product_found, - 'product_brand_id', v_product_brand_id, - 'brand_match', v_brand_match, - 'authorized', v_authorized, - 'reason', v_reason - ); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- Verify: confirm only TEXT signatures remain --- ═══════════════════════════════════════════════════════════════════════════ - -SELECT proname, oidvectortypes(proargtypes) AS arg_types -FROM pg_proc -WHERE proname IN ('assign_product_to_stop', 'unassign_product_from_stop', 'debug_stop_product_assignment') - AND pronamespace = 'public'::regnamespace; - --- ═══════════════════════════════════════════════════════════════════════════ --- Refresh PostgREST schema cache --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; --- ─────────────────────────────────────────── --- 030_normalize_dev_user_caller_uid.sql --- ─────────────────────────────────────────── --- Migration 030: Normalize dev-user prefix before UUID cast in admin lookup --- --- Root cause: p_caller_uid = 'dev-user-00000000-...' is not a valid UUID string. --- p_caller_uid::UUID throws "invalid input syntax for type uuid" before the --- admin_users lookup can run. The lookup fails even for valid admin UUIDs because --- the cast throws first. --- --- Fix: normalize p_caller_uid before casting: --- - If it starts with 'dev-user-', strip that prefix then cast to UUID --- - Otherwise cast directly --- This lets the existing NOT FOUND handling catch the dev-user case gracefully. --- --- Also adds exception handling around the cast so invalid strings don't crash --- the function — the IF NOT FOUND path handles it. --- --- Applies to: assign_product_to_stop, unassign_product_from_stop, debug_stop_product_assignment --- ═══════════════════════════════════════════════════════════════════════════ - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. assign_product_to_stop --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.assign_product_to_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_existing UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_result JSONB; - v_lookup_uid UUID; -BEGIN - -- Normalize: strip 'dev-user-' prefix before UUID cast - IF p_caller_uid LIKE 'dev-user-%' THEN - v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID; - ELSE - v_lookup_uid := p_caller_uid::UUID; - END IF; - - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Verify product - SELECT brand_id INTO v_product_brand_id - FROM products - WHERE id = p_product_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Product not found'); - END IF; - - -- Cross-brand guard - IF v_stop_brand_id != v_product_brand_id THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed' - ); - END IF; - - -- Look up admin by normalized user_id - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = v_lookup_uid - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization: platform_admin OR brand_admin for this brand - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object( - 'success', false, - 'error', 'Not authorized — requires platform_admin or brand_admin for this brand' - ); - END IF; - - -- Idempotent insert - SELECT id INTO v_existing - FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - IF v_existing IS NOT NULL THEN - RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true); - END IF; - - INSERT INTO product_stops (stop_id, product_id) - VALUES (p_stop_id, p_product_id) - RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id) - INTO v_result; - - RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. unassign_product_from_stop --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.unassign_product_from_stop( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_lookup_uid UUID; -BEGIN - -- Normalize: strip 'dev-user-' prefix before UUID cast - IF p_caller_uid LIKE 'dev-user-%' THEN - v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID; - ELSE - v_lookup_uid := p_caller_uid::UUID; - END IF; - - -- Verify stop - SELECT brand_id INTO v_stop_brand_id - FROM stops - WHERE id = p_stop_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Stop not found'); - END IF; - - -- Look up admin - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = v_lookup_uid - LIMIT 1; - - IF NOT FOUND OR v_admin_role IS NULL THEN - RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin'); - END IF; - - -- Authorization - IF v_admin_role = 'platform_admin' THEN - -- allowed - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - -- allowed - ELSE - RETURN jsonb_build_object('success', false, 'error', 'Not authorized'); - END IF; - - DELETE FROM product_stops - WHERE stop_id = p_stop_id AND product_id = p_product_id; - - RETURN jsonb_build_object('success', true, 'deleted', true); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. debug_stop_product_assignment --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment( - p_stop_id UUID, - p_product_id UUID, - p_caller_uid TEXT -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_stop_brand_id UUID; - v_product_brand_id UUID; - v_admin_role TEXT; - v_admin_brand_id UUID; - v_admin_found BOOLEAN := false; - v_stop_found BOOLEAN := false; - v_product_found BOOLEAN := false; - v_brand_match BOOLEAN; - v_authorized BOOLEAN := false; - v_reason TEXT := 'not checked'; - v_lookup_uid UUID; -BEGIN - -- Normalize: strip 'dev-user-' prefix before UUID cast - IF p_caller_uid LIKE 'dev-user-%' THEN - v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID; - ELSE - v_lookup_uid := p_caller_uid::UUID; - END IF; - - -- Check stop - SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id; - IF FOUND THEN v_stop_found := true; END IF; - - -- Check product - SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id; - IF FOUND THEN v_product_found := true; END IF; - - -- Check admin_users - BEGIN - SELECT role, brand_id INTO v_admin_role, v_admin_brand_id - FROM admin_users - WHERE user_id = v_lookup_uid; - IF FOUND THEN v_admin_found := true; END IF; - EXCEPTION WHEN OTHERS THEN - v_reason := 'admin lookup failed: ' || SQLERRM; - END; - - -- Brand match - IF v_stop_found AND v_product_found THEN - v_brand_match := (v_stop_brand_id = v_product_brand_id); - END IF; - - -- Authorization decision - IF v_admin_found AND v_stop_found AND v_product_found THEN - IF v_brand_match THEN - IF v_admin_role = 'platform_admin' THEN - v_authorized := true; v_reason := 'authorized as platform_admin'; - ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN - v_authorized := true; v_reason := 'authorized as brand_admin for this brand'; - ELSE - v_authorized := false; - v_reason := 'role "' || v_admin_role || '" with brand_id "' || - COALESCE(v_admin_brand_id::TEXT, 'NULL') || - '" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"'; - END IF; - ELSE - v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || - ', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL'); - END IF; - END IF; - - RETURN jsonb_build_object( - 'caller_uid', p_caller_uid, - 'lookup_uid', v_lookup_uid::TEXT, - 'admin_found', v_admin_found, - 'admin_role', v_admin_role, - 'admin_brand_id', v_admin_brand_id, - 'stop_found', v_stop_found, - 'stop_brand_id', v_stop_brand_id, - 'product_found', v_product_found, - 'product_brand_id', v_product_brand_id, - 'brand_match', v_brand_match, - 'authorized', v_authorized, - 'reason', v_reason - ); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- Refresh PostgREST schema cache --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; --- ─────────────────────────────────────────── --- 031_reports_v1_rpcs.sql --- ─────────────────────────────────────────── --- Migration 031: Reports V1 — Operational Reporting RPCs --- SECURITY DEFINER — bypasses RLS, enforces brand scoping in SQL --- All functions accept p_brand_id NULL = platform_admin sees all brands --- brand_admin is filtered at the page level via getAdminUser() - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. get_reports_summary — 10 KPI cards --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_reports_summary( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - -- Base WHERE: always filter by brand if provided, always filter by date - -- Status filter: exclude canceled orders from all revenue/order counts - - WITH date_orders AS ( - SELECT o.id, o.subtotal, o.status, o.brand_id, - CASE WHEN EXISTS ( - SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup' - ) THEN true ELSE false END AS has_pickup, - CASE WHEN EXISTS ( - SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping' - ) THEN true ELSE false END AS has_shipping - FROM orders o - WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date - AND o.status != 'canceled' - AND (p_brand_id IS NULL OR o.brand_id = p_brand_id) - ), - pickup_orders AS ( - SELECT COUNT(DISTINCT id) AS cnt FROM date_orders WHERE has_pickup - ), - shipping_orders AS ( - SELECT COUNT(DISTINCT id) AS cnt FROM date_orders WHERE has_shipping - ), - pending_pickups AS ( - SELECT COUNT(DISTINCT o.id) AS cnt - FROM date_orders o - WHERE o.has_pickup AND o.status = 'pending' - ), - completed_pickups AS ( - SELECT COUNT(DISTINCT o.id) AS cnt - FROM date_orders o - WHERE o.has_pickup AND o.status = 'completed' - ), - contacts_added AS ( - SELECT COUNT(*) AS cnt - FROM communication_contacts cc - WHERE cc.created_at::DATE BETWEEN p_start_date AND p_end_date - AND cc.source = 'import' - AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id) - ), - campaigns_sent AS ( - SELECT COUNT(*) AS cnt - FROM communication_campaigns cc - WHERE cc.sent_at::DATE BETWEEN p_start_date AND p_end_date - AND cc.status = 'sent' - AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id) - ), - messages_logged AS ( - SELECT COUNT(*) AS cnt - FROM communication_message_logs ml - WHERE ml.sent_at::DATE BETWEEN p_start_date AND p_end_date - AND (p_brand_id IS NULL OR ml.brand_id = p_brand_id) - ) - SELECT jsonb_build_object( - 'gross_sales', COALESCE(SUM(subtotal), 0), - 'total_orders', COUNT(DISTINCT id), - 'avg_order_value', CASE WHEN COUNT(id) > 0 THEN ROUND(AVG(subtotal), 2) ELSE 0 END, - 'pickup_orders', COALESCE((SELECT cnt FROM pickup_orders), 0), - 'shipping_orders', COALESCE((SELECT cnt FROM shipping_orders), 0), - 'pending_pickups', COALESCE((SELECT cnt FROM pending_pickups), 0), - 'completed_pickups', COALESCE((SELECT cnt FROM completed_pickups), 0), - 'contacts_added', COALESCE((SELECT cnt FROM contacts_added), 0), - 'campaigns_sent', COALESCE((SELECT cnt FROM campaigns_sent), 0), - 'messages_logged', COALESCE((SELECT cnt FROM messages_logged), 0) - ) INTO v_result - FROM date_orders; - - RETURN v_result; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. get_orders_by_stop_report — orders grouped by stop --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_orders_by_stop_report( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN COALESCE(jsonb_agg( - jsonb_build_object( - 'stop_name', r.stop_name, - 'city', r.city, - 'state', r.state, - 'date', r.date, - 'order_count', r.order_count, - 'gross_sales', r.gross_sales, - 'pending_count', r.pending_count, - 'completed_count', r.completed_count - ) ORDER BY r.date DESC - ), '[]'::JSONB) - FROM ( - SELECT - s.city || ', ' || s.state AS stop_name, - s.city, - s.state, - s.date, - COUNT(DISTINCT o.id) AS order_count, - COALESCE(SUM(o.subtotal), 0) AS gross_sales, - COUNT(DISTINCT CASE WHEN o.status = 'pending' THEN o.id END) AS pending_count, - COUNT(DISTINCT CASE WHEN o.status = 'completed' THEN o.id END) AS completed_count - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date - AND o.status != 'canceled' - AND (p_brand_id IS NULL OR o.brand_id = p_brand_id) - GROUP BY s.id, s.city, s.state, s.date - ) r; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. get_sales_by_product_report — product revenue --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_sales_by_product_report( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN COALESCE(jsonb_agg( - jsonb_build_object( - 'product_name', r.product_name, - 'units_sold', r.units_sold, - 'gross_revenue', r.gross_revenue, - 'avg_price', r.avg_price - ) ORDER BY r.gross_revenue DESC - ), '[]'::JSONB) - FROM ( - SELECT - p.name AS product_name, - SUM(oi.quantity) AS units_sold, - SUM(oi.price * oi.quantity) AS gross_revenue, - ROUND(AVG(oi.price), 2) AS avg_price - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - JOIN products p ON p.id = oi.product_id - WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date - AND o.status != 'canceled' - AND (p_brand_id IS NULL OR o.brand_id = p_brand_id) - GROUP BY p.id, p.name - ) r; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. get_fulfillment_report — pickup / shipping / mixed breakdown --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_fulfillment_report( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_total NUMERIC; -BEGIN - SELECT COALESCE(SUM(o.subtotal), 0) INTO v_total - FROM orders o - WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date - AND o.status != 'canceled' - AND (p_brand_id IS NULL OR o.brand_id = p_brand_id); - - RETURN COALESCE(jsonb_agg(sub ORDER BY revenue DESC), '[]'::JSONB) - FROM ( - SELECT - CASE - WHEN has_pickup AND has_shipping THEN 'mixed' - WHEN has_pickup THEN 'pickup' - ELSE 'shipping' - END AS fulfillment_type, - COUNT(DISTINCT o.id) AS order_count, - SUM(o.subtotal) AS revenue, - CASE WHEN v_total > 0 THEN ROUND(100.0 * SUM(o.subtotal) / v_total, 1) ELSE 0 END AS pct_of_total - FROM ( - SELECT o.id, o.subtotal, - EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup') AS has_pickup, - EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping') AS has_shipping - FROM orders o - WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date - AND o.status != 'canceled' - AND (p_brand_id IS NULL OR o.brand_id = p_brand_id) - ) o - GROUP BY fulfillment_type - ) sub; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 5. get_pickup_status_by_stop — per-stop pickup status --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_pickup_status_by_stop( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN COALESCE(jsonb_agg( - jsonb_build_object( - 'stop_name', r.stop_name, - 'city', r.city, - 'date', r.date, - 'total_orders', r.total_orders, - 'pending', r.pending, - 'completed', r.completed, - 'canceled', r.canceled - ) ORDER BY r.date DESC - ), '[]'::JSONB) - FROM ( - SELECT - s.city || ', ' || s.state AS stop_name, - s.city, - s.date, - COUNT(DISTINCT o.id) AS total_orders, - COUNT(DISTINCT CASE WHEN o.status = 'pending' THEN o.id END) AS pending, - COUNT(DISTINCT CASE WHEN o.status = 'completed' THEN o.id END) AS completed, - COUNT(DISTINCT CASE WHEN o.status = 'canceled' THEN o.id END) AS canceled - FROM orders o - JOIN stops s ON s.id = o.stop_id - WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date - AND EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup') - AND (p_brand_id IS NULL OR o.brand_id = p_brand_id) - GROUP BY s.id, s.city, s.state, s.date - ) r; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 6. get_contact_growth_report — new contacts over time --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_contact_growth_report( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN COALESCE(jsonb_agg( - jsonb_build_object( - 'date', r.date, - 'new_contacts', r.new_contacts, - 'imports', r.imports, - 'total', r.total - ) ORDER BY r.date DESC - ), '[]'::JSONB) - FROM ( - SELECT - d::DATE AS date, - COALESCE(SUM(CASE WHEN cc.source IS DISTINCT FROM 'import' THEN 1 ELSE 0 END), 0) AS new_contacts, - COALESCE(SUM(CASE WHEN cc.source = 'import' THEN 1 ELSE 0 END), 0) AS imports, - COUNT(cc.*) AS total - FROM generate_series(p_start_date, p_end_date, '1 day'::INTERVAL) d - LEFT JOIN communication_contacts cc - ON cc.created_at::DATE = d::DATE - AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id) - GROUP BY d::DATE - ) r; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 7. get_campaign_activity_report — campaign status and message counts --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION public.get_campaign_activity_report( - p_brand_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN COALESCE(jsonb_agg( - jsonb_build_object( - 'campaign_name', c.name, - 'status', c.status, - 'campaign_type', c.campaign_type, - 'sent_at', c.sent_at, - 'messages_logged', COALESCE(ml.sent_count, 0) - ) ORDER BY c.sent_at DESC NULLS LAST - ), '[]'::JSONB) - FROM communication_campaigns c - LEFT JOIN ( - SELECT campaign_id, COUNT(*) AS sent_count - FROM communication_message_logs - WHERE sent_at::DATE BETWEEN p_start_date AND p_end_date - GROUP BY campaign_id - ) ml ON ml.campaign_id = c.id - WHERE c.created_at::DATE BETWEEN p_start_date AND p_end_date - AND (p_brand_id IS NULL OR c.brand_id = p_brand_id); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- Refresh PostgREST schema cache --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; --- ─────────────────────────────────────────── --- 032_store_employee_role.sql --- ─────────────────────────────────────────── --- Migration 032: Store Employee Role --- --- Add store_employee to the platform's role model. No new columns — --- admin_users.role already exists with values 'platform_admin', 'brand_admin'. --- store_employee just adds a third recognized role. --- --- RLS: store_employee can read orders for their brand (brand_id scoped). --- SQL RPCs: get_admin_orders + get_admin_order_detail add store_employee brand scoping. --- No changes to product/stop/communications/settings RLS — store_employee shouldn't --- be granted those tables in RLS at all (they go through existing SECURITY DEFINER RPCs --- with explicit can_manage_* guard checks in TypeScript actions). - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. RLS policy: store_employee can read their brand's orders --- ═══════════════════════════════════════════════════════════════════════════ - -DROP POLICY IF EXISTS "Store employee can read their brand orders" ON orders; -CREATE POLICY "Store employee can read their brand orders" - ON orders FOR SELECT TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'store_employee' - AND admin_users.brand_id = ( - SELECT brand_id FROM stops WHERE stops.id = orders.stop_id - ) - ) - ); - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. get_admin_orders — add store_employee brand scoping --- (SECURITY DEFINER, no RLS — add explicit brand_id check for store_employee) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION get_admin_orders(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_orders JSONB; - v_stops JSONB; - v_effective_brand_id UUID; -BEGIN - -- Resolve effective brand: use p_brand_id if non-null, - -- otherwise fall back to store_employee's own brand - IF p_brand_id IS NOT NULL THEN - v_effective_brand_id := p_brand_id; - ELSE - -- For store_employee with no p_brand_id, use their own brand_id - -- (caller must pass brand_id for store_employee to avoid cross-brand data) - v_effective_brand_id := NULL; - END IF; - - IF v_effective_brand_id IS NULL THEN - -- platform_admin or no brand scoping — return all orders - SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB) - INTO v_orders - FROM ( - SELECT o.id, o.customer_name, o.customer_email, o.customer_phone, - o.stop_id, o.status, o.subtotal, o.pickup_complete, - o.pickup_completed_at, o.pickup_completed_by, o.created_at, - CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) END as stops - FROM orders o LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.stop_id IS NOT NULL - LIMIT 500 - ) t; - - SELECT COALESCE(jsonb_agg(jsonb_build_object( - 'id', id, 'city', city, 'state', state, - 'date', date, 'time', time, 'location', location, 'brand_id', brand_id - ) ORDER BY date), '[]'::JSONB) - INTO v_stops FROM stops WHERE active = true; - ELSE - -- brand-scoped query (brand_admin, store_employee, or platform_admin filtering) - SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB) - INTO v_orders - FROM ( - SELECT o.id, o.customer_name, o.customer_email, o.customer_phone, - o.stop_id, o.status, o.subtotal, o.pickup_complete, - o.pickup_completed_at, o.pickup_completed_by, o.created_at, - jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) as stops - FROM orders o JOIN stops s ON o.stop_id = s.id - WHERE s.brand_id = v_effective_brand_id - LIMIT 500 - ) t; - - SELECT COALESCE(jsonb_agg(jsonb_build_object( - 'id', id, 'city', city, 'state', state, - 'date', date, 'time', time, 'location', location, 'brand_id', brand_id - ) ORDER BY date), '[]'::JSONB) - INTO v_stops FROM stops WHERE active = true AND brand_id = v_effective_brand_id; - END IF; - - RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops); -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. get_admin_order_detail — brand scoping (already SECURITY DEFINER, --- add check that store_employee can only view orders in their brand) --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION get_admin_order_detail(p_order_id UUID, p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_order JSONB; - v_order_brand_id UUID; - v_stop_brand_id UUID; -BEGIN - -- Resolve order's brand_id (from order.brand_id or stop.brand_id) - SELECT - COALESCE(o.brand_id, s.brand_id), - s.brand_id - INTO v_order_brand_id, v_stop_brand_id - FROM orders o - LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.id = p_order_id; - - -- Brand scoping: if p_brand_id is provided, restrict to that brand - IF p_brand_id IS NOT NULL AND v_order_brand_id IS NOT NULL AND v_order_brand_id != p_brand_id THEN - RETURN jsonb_build_object('error', 'Order not found or access denied'); - END IF; - - SELECT jsonb_build_object( - 'id', o.id, - 'customer_name', o.customer_name, - 'customer_email', o.customer_email, - 'customer_phone', o.customer_phone, - 'stop_id', o.stop_id, - 'status', o.status, - 'subtotal', o.subtotal, - 'pickup_complete', o.pickup_complete, - 'pickup_completed_at', o.pickup_completed_at, - 'pickup_completed_by', o.pickup_completed_by, - 'created_at', o.created_at, - 'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object( - 'id', s.id, 'city', s.city, 'state', s.state, - 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id - ) END, - 'order_items', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'id', oi.id, 'product_id', oi.product_id, 'product_name', p.name, - 'quantity', oi.quantity, 'price', oi.price, 'fulfillment', oi.fulfillment, - 'products', jsonb_build_object('name', p.name) - )) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id - ), '[]'::JSONB) - ) - INTO v_order - FROM orders o - LEFT JOIN stops s ON o.stop_id = s.id - WHERE o.id = p_order_id; - - RETURN v_order; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. Refresh PostgREST schema cache --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; --- ─────────────────────────────────────────── --- 033_admin_users_crud.sql --- ─────────────────────────────────────────── --- Migration 033: Admin Users CRUD --- --- RPC functions for listing, creating, updating, and deactivating admin_users. --- Security constraints enforced at SQL level: --- - platform_admin: can manage all users and any brand --- - brand_admin with can_manage_users: can only manage users within their brand, --- cannot create platform_admin, cannot assign foreign brand_id --- - store_employee: cannot access these functions (guarded at TS route level) --- --- Display name: joins auth.users raw_user_meta_data -> display_name | name, falls back to email. - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. get_admin_users — list users, optionally filtered by brand --- ═══════════════════════════════════════════════════════════════════════════ - -DROP FUNCTION IF EXISTS get_admin_users(UUID); -CREATE OR REPLACE FUNCTION get_admin_users(p_brand_id UUID DEFAULT NULL) -RETURNS TABLE ( - id UUID, - user_id UUID, - display_name TEXT, - email TEXT, - role TEXT, - brand_id UUID, - brand_name TEXT, - can_manage_products BOOLEAN, - can_manage_stops BOOLEAN, - can_manage_orders BOOLEAN, - can_manage_pickup BOOLEAN, - can_manage_messages BOOLEAN, - can_manage_refunds BOOLEAN, - can_manage_users BOOLEAN, - can_manage_water_log BOOLEAN, - can_manage_reports BOOLEAN, - active BOOLEAN, - created_at TIMESTAMPTZ, - last_login TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN QUERY - SELECT - au.id, - au.user_id, - COALESCE( - (au.raw_user_meta_data->>'display_name')::TEXT, - (au.raw_user_meta_data->>'full_name')::TEXT, - u.email - ) AS display_name, - u.email, - au.role::TEXT, - au.brand_id, - b.name AS brand_name, - au.can_manage_products, - au.can_manage_stops, - au.can_manage_orders, - au.can_manage_pickup, - au.can_manage_messages, - au.can_manage_refunds, - au.can_manage_users, - au.can_manage_water_log, - COALESCE(au.can_manage_reports, false) AS can_manage_reports, - au.active, - au.created_at, - au.last_login - FROM admin_users au - JOIN auth.users u ON au.user_id = u.id - LEFT JOIN brands b ON au.brand_id = b.id - WHERE - -- platform_admin sees all; brand_admin sees only their brand - ( - EXISTS ( - SELECT 1 FROM admin_users caller - WHERE caller.user_id = auth.uid() - AND caller.role = 'platform_admin' - ) - OR - ( - EXISTS ( - SELECT 1 FROM admin_users caller - WHERE caller.user_id = auth.uid() - AND caller.role = 'brand_admin' - AND caller.can_manage_users = true - ) - AND (p_brand_id IS NULL OR au.brand_id = p_brand_id) - AND ( - EXISTS ( - SELECT 1 FROM admin_users caller - WHERE caller.user_id = auth.uid() - AND caller.brand_id = au.brand_id - ) - ) - ) - ) - ORDER BY au.created_at DESC; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. create_admin_user — create a new admin user record --- --- p_email: must already have a Supabase auth account --- p_role: 'platform_admin' | 'brand_admin' | 'store_employee' --- p_brand_id: required for brand_admin and store_employee --- p_flags: JSONB with individual can_manage_* booleans --- ═══════════════════════════════════════════════════════════════════════════ - -DROP FUNCTION IF EXISTS create_admin_user(TEXT, TEXT, UUID, JSONB); -CREATE OR REPLACE FUNCTION create_admin_user( - p_email TEXT, - p_role TEXT, - p_brand_id UUID DEFAULT NULL, - p_flags JSONB DEFAULT '{}'::JSONB -) -RETURNS TABLE ( - id UUID, - user_id UUID, - display_name TEXT, - email TEXT, - role TEXT, - brand_id UUID, - active BOOLEAN, - created_at TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_caller_role TEXT; - v_caller_brand_id UUID; - v_caller_can_manage_users BOOLEAN; - v_target_user_id UUID; - v_new_id UUID; -BEGIN - -- Caller must be authenticated - IF auth.uid() IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - -- Look up caller - SELECT role, brand_id, can_manage_users - INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users - FROM admin_users - WHERE user_id = auth.uid(); - - IF v_caller_role IS NULL THEN - RAISE EXCEPTION 'Not an admin'; - END IF; - - -- Security: brand_admin cannot create platform_admin - IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN - RAISE EXCEPTION 'Insufficient permissions to create a platform_admin'; - END IF; - - -- Security: brand_admin can only create users in their own brand - IF v_caller_role = 'brand_admin' THEN - IF p_brand_id IS DISTINCT FROM v_caller_brand_id THEN - RAISE EXCEPTION 'Insufficient permissions to create a user for another brand'; - END IF; - IF p_brand_id IS NULL AND p_role = 'platform_admin' THEN - RAISE EXCEPTION 'brand_admin cannot create platform_admin'; - END IF; - END IF; - - -- Find the auth user by email - BEGIN - SELECT id INTO v_target_user_id FROM auth.users WHERE email = p_email; - EXCEPTION WHEN OTHERS THEN - RAISE EXCEPTION 'Auth user with email % not found', p_email; - END; - - IF v_target_user_id IS NULL THEN - RAISE EXCEPTION 'Auth user with email % not found', p_email; - END IF; - - -- Check no existing admin_users record for this user - IF EXISTS (SELECT 1 FROM admin_users WHERE user_id = v_target_user_id) THEN - RAISE EXCEPTION 'Admin user with email % already exists', p_email; - END IF; - - -- Insert new admin user - INSERT INTO admin_users ( - user_id, role, brand_id, - can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup, - can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, - can_manage_reports, active - ) VALUES ( - v_target_user_id, p_role, p_brand_id, - COALESCE((p_flags->>'can_manage_products')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_users')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, false), - true - ) - RETURNING id INTO v_new_id; - - RETURN QUERY - SELECT - au.id, au.user_id, - COALESCE( - (au.raw_user_meta_data->>'display_name')::TEXT, - (au.raw_user_meta_data->>'full_name')::TEXT, - u.email - ) AS display_name, - u.email, au.role::TEXT, au.brand_id, au.active, au.created_at - FROM admin_users au - JOIN auth.users u ON au.user_id = u.id - WHERE au.id = v_new_id; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. update_admin_user — update role, brand, flags, or active status --- ═══════════════════════════════════════════════════════════════════════════ - -DROP FUNCTION IF EXISTS update_admin_user(UUID, TEXT, UUID, JSONB, BOOLEAN); -CREATE OR REPLACE FUNCTION update_admin_user( - p_id UUID, - p_role TEXT DEFAULT NULL, - p_brand_id UUID DEFAULT NULL, - p_flags JSONB DEFAULT NULL, - p_active BOOLEAN DEFAULT NULL -) -RETURNS TABLE ( - id UUID, - user_id UUID, - display_name TEXT, - email TEXT, - role TEXT, - brand_id UUID, - active BOOLEAN, - updated_at TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_caller_role TEXT; - v_caller_brand_id UUID; - v_caller_can_manage_users BOOLEAN; - v_target_role TEXT; - v_target_brand_id UUID; -BEGIN - IF auth.uid() IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT role, brand_id, can_manage_users - INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users - FROM admin_users - WHERE user_id = auth.uid(); - - IF v_caller_role IS NULL THEN - RAISE EXCEPTION 'Not an admin'; - END IF; - - -- Cannot update your own account's role - IF EXISTS (SELECT 1 FROM admin_users WHERE id = p_id AND user_id = auth.uid()) THEN - RAISE EXCEPTION 'Cannot update your own admin account'; - END IF; - - -- Look up target - SELECT role, brand_id INTO v_target_role, v_target_brand_id - FROM admin_users WHERE id = p_id; - - IF v_target_role IS NULL THEN - RAISE EXCEPTION 'Admin user not found'; - END IF; - - -- Security: brand_admin cannot demote/promote platform_admin - IF v_caller_role = 'brand_admin' AND v_target_role = 'platform_admin' AND p_role IS NOT NULL THEN - RAISE EXCEPTION 'Insufficient permissions to modify a platform_admin'; - END IF; - - -- Security: brand_admin cannot give platform_admin role - IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN - RAISE EXCEPTION 'Insufficient permissions to create a platform_admin'; - END IF; - - -- Security: brand_admin can only affect users in their brand - IF v_caller_role = 'brand_admin' THEN - IF v_target_brand_id != v_caller_brand_id THEN - RAISE EXCEPTION 'Insufficient permissions to modify a user from another brand'; - END IF; - IF p_brand_id IS NOT NULL AND p_brand_id != v_caller_brand_id THEN - RAISE EXCEPTION 'Insufficient permissions to assign a user to another brand'; - END IF; - IF p_role = 'platform_admin' THEN - RAISE EXCEPTION 'Insufficient permissions to create a platform_admin'; - END IF; - END IF; - - -- Apply updates - UPDATE admin_users SET - role = COALESCE(p_role, role), - brand_id = COALESCE(p_brand_id, brand_id), - can_manage_products = COALESCE((p_flags->>'can_manage_products')::BOOLEAN, can_manage_products), - can_manage_stops = COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, can_manage_stops), - can_manage_orders = COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, can_manage_orders), - can_manage_pickup = COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, can_manage_pickup), - can_manage_messages = COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, can_manage_messages), - can_manage_refunds = COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, can_manage_refunds), - can_manage_users = COALESCE((p_flags->>'can_manage_users')::BOOLEAN, can_manage_users), - can_manage_water_log = COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, can_manage_water_log), - can_manage_reports = COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, can_manage_reports), - active = COALESCE(p_active, active) - WHERE id = p_id; - - RETURN QUERY - SELECT - au.id, au.user_id, - COALESCE( - (au.raw_user_meta_data->>'display_name')::TEXT, - (au.raw_user_meta_data->>'full_name')::TEXT, - u.email - ) AS display_name, - u.email, au.role::TEXT, au.brand_id, au.active, NOW() AS updated_at - FROM admin_users au - JOIN auth.users u ON au.user_id = u.id - WHERE au.id = p_id; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. delete_admin_user — remove admin user record --- Cannot delete your own account. --- ═══════════════════════════════════════════════════════════════════════════ - -DROP FUNCTION IF EXISTS delete_admin_user(UUID); -CREATE OR REPLACE FUNCTION delete_admin_user(p_id UUID) -RETURNS BOOLEAN -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_caller_role TEXT; - v_target_user_id UUID; -BEGIN - IF auth.uid() IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT role INTO v_caller_role FROM admin_users WHERE user_id = auth.uid(); - IF v_caller_role IS NULL THEN - RAISE EXCEPTION 'Not an admin'; - END IF; - - -- Cannot delete self - SELECT user_id INTO v_target_user_id FROM admin_users WHERE id = p_id; - IF v_target_user_id = auth.uid() THEN - RAISE EXCEPTION 'Cannot delete your own admin account'; - END IF; - - -- brand_admin can only delete users in their brand - IF v_caller_role = 'brand_admin' THEN - IF NOT EXISTS ( - SELECT 1 FROM admin_users - WHERE id = p_id AND brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid()) - ) THEN - RAISE EXCEPTION 'Insufficient permissions to delete this user'; - END IF; - END IF; - - DELETE FROM admin_users WHERE id = p_id; - RETURN true; -END; -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 5. Refresh PostgREST schema cache --- ═══════════════════════════════════════════════════════════════════════════ - -NOTIFY pgrst, 'reload schema'; --- ─────────────────────────────────────────── --- 034_password_change_flow.sql --- ─────────────────────────────────────────── --- Migration: 034_password_change_flow --- Adds must_change_password column to admin_users if not exists --- Adds phone_number column to admin_users if not exists --- Creates RPC to clear must_change_password after password update - -alter table admin_users add column if not exists must_change_password boolean not null default false; -alter table admin_users add column if not exists phone_number text; - --- RPC: clear must_change_password for the authenticated user -create or replace function clear_must_change_password() -returns boolean -language plpgsql -security definer set search_path = public -as $$ -begin - update admin_users - set must_change_password = false - where user_id = auth.uid() - and must_change_password = true; - - return true; -end; -$$; - --- ─────────────────────────────────────────── --- 035_admin_action_logs.sql --- ─────────────────────────────────────────── --- Migration: 035_admin_action_logs --- Dedicated audit trail for admin user management actions - -CREATE TABLE IF NOT EXISTS admin_action_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - action_type TEXT NOT NULL, -- 'create' | 'update' | 'delete' - admin_id UUID, -- auth.uid() of the admin performing the action - admin_email TEXT, - affected_user_id UUID, -- the admin_users.id being modified - brand_id UUID, - details JSONB DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Index for fast lookups by acting admin -CREATE INDEX IF NOT EXISTS idx_admin_action_logs_admin_id ON admin_action_logs(admin_id); --- Index for fast lookups by affected user -CREATE INDEX IF NOT EXISTS idx_admin_action_logs_affected_user ON admin_action_logs(affected_user_id); --- Index for time-based queries -CREATE INDEX IF NOT EXISTS idx_admin_action_logs_created_at ON admin_action_logs(created_at DESC); - --- RLS: platform_admin can read all; brand_admin reads only their brand -ALTER TABLE admin_action_logs ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "platform_admin_read_all_admin_action_logs" ON admin_action_logs - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - ) - ); - -CREATE POLICY "brand_admin_read_own_brand_admin_action_logs" ON admin_action_logs - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'brand_admin' - AND au.brand_id = admin_action_logs.brand_id - ) - ); - --- Append-only: any authenticated admin user can insert (enforced at RPC level) -CREATE POLICY "admin_action_logs_insert" ON admin_action_logs - FOR INSERT WITH CHECK (auth.uid() IS NOT NULL); - --- ============================================================ --- log_admin_action — SECURITY DEFINER, bypasses RLS --- Called by admin user CRUD actions to record what changed. --- Payload: { action_type, admin_id, admin_email, affected_user_id, brand_id, details } --- ============================================================ -CREATE OR REPLACE FUNCTION log_admin_action(p_payload JSONB) -RETURNS UUID -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_log_id UUID; -BEGIN - INSERT INTO admin_action_logs ( - action_type, admin_id, admin_email, affected_user_id, brand_id, details - ) VALUES ( - p_payload->>'action_type', - CASE WHEN (p_payload->>'admin_id') IS NULL THEN NULL ELSE (p_payload->>'admin_id')::UUID END, - p_payload->>'admin_email', - CASE WHEN (p_payload->>'affected_user_id') IS NULL THEN NULL ELSE (p_payload->>'affected_user_id')::UUID END, - CASE WHEN (p_payload->>'brand_id') IS NULL THEN NULL ELSE (p_payload->>'brand_id')::UUID END, - COALESCE(p_payload->'details', '{}'::JSONB) - ) - RETURNING id INTO v_log_id; - - RETURN v_log_id; -END; -$$; - --- ============================================================ --- log_user_activity — SECURITY DEFINER, bypasses RLS --- Tracks per-user activities: logins, password changes, profile updates. --- Payload: { user_id, activity_type, details, ip_address, user_agent } --- ============================================================ -CREATE OR REPLACE FUNCTION log_user_activity(p_payload JSONB) -RETURNS UUID -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_log_id UUID; -BEGIN - INSERT INTO user_activity_logs ( - user_id, activity_type, details, ip_address, user_agent - ) VALUES ( - CASE WHEN (p_payload->>'user_id') IS NULL THEN NULL ELSE (p_payload->>'user_id')::UUID END, - p_payload->>'activity_type', - COALESCE(p_payload->'details', '{}'::JSONB), - p_payload->>'ip_address', - p_payload->>'user_agent' - ) - RETURNING id INTO v_log_id; - - RETURN v_log_id; -END; -$$; - - --- ─────────────────────────────────────────── --- 036_user_activity_logs.sql --- ─────────────────────────────────────────── --- Migration: 036_user_activity_logs --- Tracks per-user activities: logins, password changes, profile updates - -CREATE TABLE IF NOT EXISTS user_activity_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL, - activity_type TEXT NOT NULL, -- 'login' | 'logout' | 'password_change' | 'profile_update' | 'email_change' - details JSONB DEFAULT '{}', - ip_address TEXT, - user_agent TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Index for fast per-user log lookups -CREATE INDEX IF NOT EXISTS idx_user_activity_logs_user_id ON user_activity_logs(user_id); --- Index for time-based queries per user -CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at DESC); - --- RLS: user can read own logs; platform_admin can read all -ALTER TABLE user_activity_logs ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "user_read_own_activity_logs" ON user_activity_logs - FOR SELECT USING (auth.uid() = user_id); - -CREATE POLICY "platform_admin_read_all_user_activity_logs" ON user_activity_logs - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.user_id = auth.uid() - AND au.role = 'platform_admin' - ) - ); - --- Any authenticated user can insert their own log entries -CREATE POLICY "user_insert_own_activity_logs" ON user_activity_logs - FOR INSERT WITH CHECK (auth.uid() = user_id); - --- ─────────────────────────────────────────── --- 037_update_admin_user_profile.sql --- ─────────────────────────────────────────── --- Migration: 037_update_admin_user_profile --- Extends update_admin_user to accept display_name and phone_number. --- Updates admin_users and syncs auth.users raw_user_meta_data. --- After update, writes to admin_action_logs. - -DROP FUNCTION IF EXISTS update_admin_user(UUID, TEXT, UUID, JSONB, BOOLEAN, TEXT, TEXT); -CREATE OR REPLACE FUNCTION update_admin_user( - p_id UUID, - p_role TEXT DEFAULT NULL, - p_brand_id UUID DEFAULT NULL, - p_flags JSONB DEFAULT NULL, - p_active BOOLEAN DEFAULT NULL, - p_display_name TEXT DEFAULT NULL, - p_phone_number TEXT DEFAULT NULL -) -RETURNS TABLE ( - id UUID, - user_id UUID, - display_name TEXT, - email TEXT, - role TEXT, - brand_id UUID, - active BOOLEAN, - phone_number TEXT, - updated_at TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_caller_role TEXT; - v_caller_brand_id UUID; - v_caller_can_manage_users BOOLEAN; - v_target_role TEXT; - v_target_brand_id UUID; - v_target_user_id UUID; - v_admin_email TEXT; -BEGIN - IF auth.uid() IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT role, brand_id, can_manage_users - INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users - FROM admin_users - WHERE user_id = auth.uid(); - - IF v_caller_role IS NULL THEN - RAISE EXCEPTION 'Not an admin'; - END IF; - - -- Cannot update your own account's role - IF EXISTS (SELECT 1 FROM admin_users WHERE id = p_id AND user_id = auth.uid()) THEN - RAISE EXCEPTION 'Cannot update your own admin account'; - END IF; - - -- Look up target - SELECT role, brand_id, user_id INTO v_target_role, v_target_brand_id, v_target_user_id - FROM admin_users WHERE id = p_id; - - IF v_target_role IS NULL THEN - RAISE EXCEPTION 'Admin user not found'; - END IF; - - -- Security: brand_admin cannot demote/promote platform_admin - IF v_caller_role = 'brand_admin' AND v_target_role = 'platform_admin' AND p_role IS NOT NULL THEN - RAISE EXCEPTION 'Insufficient permissions to modify a platform_admin'; - END IF; - - -- Security: brand_admin cannot give platform_admin role - IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN - RAISE EXCEPTION 'Insufficient permissions to create a platform_admin'; - END IF; - - -- Security: brand_admin can only affect users in their brand - IF v_caller_role = 'brand_admin' THEN - IF v_target_brand_id != v_caller_brand_id THEN - RAISE EXCEPTION 'Insufficient permissions to modify a user from another brand'; - END IF; - IF p_brand_id IS NOT NULL AND p_brand_id != v_caller_brand_id THEN - RAISE EXCEPTION 'Insufficient permissions to assign a user to another brand'; - END IF; - IF p_role = 'platform_admin' THEN - RAISE EXCEPTION 'Insufficient permissions to create a platform_admin'; - END IF; - END IF; - - -- Get caller email for audit log - SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid(); - - -- Apply updates to admin_users - UPDATE admin_users SET - role = COALESCE(p_role, role), - brand_id = COALESCE(p_brand_id, brand_id), - can_manage_products = COALESCE((p_flags->>'can_manage_products')::BOOLEAN, can_manage_products), - can_manage_stops = COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, can_manage_stops), - can_manage_orders = COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, can_manage_orders), - can_manage_pickup = COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, can_manage_pickup), - can_manage_messages = COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, can_manage_messages), - can_manage_refunds = COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, can_manage_refunds), - can_manage_users = COALESCE((p_flags->>'can_manage_users')::BOOLEAN, can_manage_users), - can_manage_water_log = COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, can_manage_water_log), - can_manage_reports = COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, can_manage_reports), - active = COALESCE(p_active, active), - display_name = COALESCE(p_display_name, display_name), - phone_number = COALESCE(p_phone_number, phone_number) - WHERE id = p_id; - - -- Sync display_name / phone_number to auth.users raw_user_meta_data - IF p_display_name IS NOT NULL OR p_phone_number IS NOT NULL THEN - UPDATE auth.users SET - raw_user_meta_data = jsonb_strip_nulls( - jsonb_set( - COALESCE(raw_user_meta_data, '{}'::jsonb), - '{display_name}', - to_jsonb(p_display_name) - ) || - jsonb_set( - COALESCE(raw_user_meta_data, '{}'::jsonb), - '{phone_number}', - to_jsonb(p_phone_number) - ) - ) - WHERE id = v_target_user_id; - END IF; - - -- Audit log: record the action - PERFORM log_admin_action(jsonb_build_object( - 'action_type', 'update', - 'admin_id', auth.uid(), - 'admin_email', v_admin_email, - 'affected_user_id', v_target_user_id, - 'brand_id', COALESCE(p_brand_id, v_target_brand_id), - 'details', jsonb_build_object( - 'changed_fields', ARRAY_REMOVE(ARRAY[ - CASE WHEN p_role IS NOT NULL THEN 'role' ELSE NULL END, - CASE WHEN p_brand_id IS NOT NULL THEN 'brand_id' ELSE NULL END, - CASE WHEN p_flags IS NOT NULL THEN 'flags' ELSE NULL END, - CASE WHEN p_active IS NOT NULL THEN 'active' ELSE NULL END, - CASE WHEN p_display_name IS NOT NULL THEN 'display_name' ELSE NULL END, - CASE WHEN p_phone_number IS NOT NULL THEN 'phone_number' ELSE NULL END - ], NULL) - ) - )); - - RETURN QUERY - SELECT - au.id, au.user_id, - COALESCE( - (au.raw_user_meta_data->>'display_name')::TEXT, - u.email - ) AS display_name, - u.email, au.role::TEXT, au.brand_id, au.active, - au.phone_number, NOW() AS updated_at - FROM admin_users au - JOIN auth.users u ON au.user_id = u.id - WHERE au.id = p_id; -END; -$$; - --- Update create_admin_user to also log -CREATE OR REPLACE FUNCTION create_admin_user( - p_email TEXT, - p_role TEXT, - p_brand_id UUID DEFAULT NULL, - p_flags JSONB DEFAULT '{}'::JSONB -) -RETURNS TABLE ( - id UUID, - user_id UUID, - display_name TEXT, - email TEXT, - role TEXT, - brand_id UUID, - active BOOLEAN, - created_at TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_caller_role TEXT; - v_caller_brand_id UUID; - v_caller_can_manage_users BOOLEAN; - v_target_user_id UUID; - v_new_id UUID; - v_admin_email TEXT; -BEGIN - IF auth.uid() IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT role, brand_id, can_manage_users - INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users - FROM admin_users - WHERE user_id = auth.uid(); - - IF v_caller_role IS NULL THEN - RAISE EXCEPTION 'Not an admin'; - END IF; - - IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN - RAISE EXCEPTION 'Insufficient permissions to create a platform_admin'; - END IF; - - IF v_caller_role = 'brand_admin' THEN - IF p_brand_id IS DISTINCT FROM v_caller_brand_id THEN - RAISE EXCEPTION 'Insufficient permissions to create a user for another brand'; - END IF; - IF p_brand_id IS NULL AND p_role = 'platform_admin' THEN - RAISE EXCEPTION 'brand_admin cannot create platform_admin'; - END IF; - END IF; - - BEGIN - SELECT id INTO v_target_user_id FROM auth.users WHERE email = p_email; - EXCEPTION WHEN OTHERS THEN - RAISE EXCEPTION 'Auth user with email % not found', p_email; - END; - - IF v_target_user_id IS NULL THEN - RAISE EXCEPTION 'Auth user with email % not found', p_email; - END IF; - - IF EXISTS (SELECT 1 FROM admin_users WHERE user_id = v_target_user_id) THEN - RAISE EXCEPTION 'Admin user with email % already exists', p_email; - END IF; - - -- Get caller email for audit log - SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid(); - - INSERT INTO admin_users ( - user_id, role, brand_id, - can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup, - can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, - can_manage_reports, active - ) VALUES ( - v_target_user_id, p_role, p_brand_id, - COALESCE((p_flags->>'can_manage_products')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_users')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, false), - COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, false), - true - ) - RETURNING id INTO v_new_id; - - -- Audit log: record the creation - PERFORM log_admin_action(jsonb_build_object( - 'action_type', 'create', - 'admin_id', auth.uid(), - 'admin_email', v_admin_email, - 'affected_user_id', v_target_user_id, - 'brand_id', p_brand_id, - 'details', jsonb_build_object('new_role', p_role) - )); - - RETURN QUERY - SELECT - au.id, au.user_id, - COALESCE( - (au.raw_user_meta_data->>'display_name')::TEXT, - (au.raw_user_meta_data->>'full_name')::TEXT, - u.email - ) AS display_name, - u.email, au.role::TEXT, au.brand_id, au.active, au.created_at - FROM admin_users au - JOIN auth.users u ON au.user_id = u.id - WHERE au.id = v_new_id; -END; -$$; - --- Update delete_admin_user to also log -CREATE OR REPLACE FUNCTION delete_admin_user(p_id UUID) -RETURNS BOOLEAN -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_caller_role TEXT; - v_target_user_id UUID; - v_admin_email TEXT; - v_target_brand_id UUID; -BEGIN - IF auth.uid() IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT role INTO v_caller_role FROM admin_users WHERE user_id = auth.uid(); - IF v_caller_role IS NULL THEN - RAISE EXCEPTION 'Not an admin'; - END IF; - - SELECT user_id, brand_id INTO v_target_user_id, v_target_brand_id - FROM admin_users WHERE id = p_id; - - SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid(); - - IF v_target_user_id = auth.uid() THEN - RAISE EXCEPTION 'Cannot delete your own admin account'; - END IF; - - IF v_caller_role = 'brand_admin' THEN - IF NOT EXISTS ( - SELECT 1 FROM admin_users - WHERE id = p_id AND brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid()) - ) THEN - RAISE EXCEPTION 'Insufficient permissions to delete this user'; - END IF; - END IF; - - -- Audit log: record the deletion - PERFORM log_admin_action(jsonb_build_object( - 'action_type', 'delete', - 'admin_id', auth.uid(), - 'admin_email', v_admin_email, - 'affected_user_id', v_target_user_id, - 'brand_id', v_target_brand_id, - 'details', jsonb_build_object('deleted_admin_id', p_id) - )); - - DELETE FROM admin_users WHERE id = p_id; - RETURN true; -END; -$$; - -NOTIFY pgrst, 'reload schema'; - --- ─────────────────────────────────────────── --- 038_product_images.sql --- ─────────────────────────────────────────── --- Migration 038: Product Images + Public Site Polish --- Idempotent: ADD COLUMN IF NOT EXISTS, CREATE OR REPLACE FUNCTION - --- ── 1. Add image_url to products ─────────────────────────────────────────────── -ALTER TABLE products ADD COLUMN IF NOT EXISTS image_url TEXT; - --- ── 2. Update get_stop_products to include image_url ────────────────────────── -CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - RETURN jsonb_build_object('products', ( - SELECT COALESCE(jsonb_agg( - jsonb_build_object( - 'id', ps.id, - 'product_id', ps.product_id, - 'name', p.name, - 'type', p.type, - 'price', p.price, - 'image_url', p.image_url - ) - ), '[]'::JSONB) - FROM product_stops ps - JOIN products p ON p.id = ps.product_id - WHERE ps.stop_id = p_stop_id - )); -END; -$$; - -NOTIFY pgrst, 'reload schema'; - --- ─────────────────────────────────────────── --- 039_shipping_fulfillment.sql --- ─────────────────────────────────────────── --- Migration 039: Shipping Fulfillment Fields --- Idempotent: ADD COLUMN IF NOT EXISTS - -ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_status TEXT NOT NULL DEFAULT 'pending'; -ALTER TABLE orders ADD COLUMN IF NOT EXISTS tracking_number TEXT; - -COMMENT ON COLUMN orders.shipping_status IS 'pending | label_created | shipped | delivered | returned'; -COMMENT ON COLUMN orders.tracking_number IS 'Carrier tracking number'; - --- ─────────────────────────────────────────── --- 040_shipping_fulfillment_rpcs.sql --- ─────────────────────────────────────────── --- Migration 040: Shipping Fulfillment RPCs --- Idempotent: CREATE OR REPLACE FUNCTION - --- ── 1. update_shipping_order ────────────────────────────────────────────────── --- Updates shipping_status and tracking_number for an order. --- Requires can_manage_orders permission (checked in server action via getAdminUser). - -CREATE OR REPLACE FUNCTION public.update_shipping_order( - p_order_id UUID, - p_shipping_status TEXT, - p_tracking_number TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - UPDATE orders SET - shipping_status = p_shipping_status, - tracking_number = p_tracking_number, - updated_at = now() - WHERE id = p_order_id; - - IF NOT FOUND THEN - RETURN jsonb_build_object('success', false, 'error', 'Order not found'); - END IF; - - RETURN jsonb_build_object('success', true); -END; -$$; - --- ── 2. get_shipping_orders ─────────────────────────────────────────────────── --- Returns orders that contain at least one shipping-line item. --- Filtered by brand_id when p_brand_id is provided. - -CREATE OR REPLACE FUNCTION public.get_shipping_orders(p_brand_id UUID DEFAULT NULL) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_agg( - jsonb_build_object( - 'id', o.id, - 'customer_name', o.customer_name, - 'customer_email', o.customer_email, - 'customer_phone', o.customer_phone, - 'status', o.status, - 'subtotal', o.subtotal, - 'shipping_status', o.shipping_status, - 'tracking_number', o.tracking_number, - 'created_at', o.created_at, - 'brand_id', o.brand_id, - 'order_items', COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'id', oi.id, - 'product_id', oi.product_id, - 'quantity', oi.quantity, - 'price', oi.price, - 'fulfillment', oi.fulfillment, - 'products', jsonb_build_object('name', p.name) - )) - FROM order_items oi - JOIN products p ON oi.product_id = p.id - WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping' - ), '[]'::JSONB) - ) - ) - INTO v_result - FROM orders o - WHERE o.id IN ( - SELECT DISTINCT oi.order_id - FROM order_items oi - WHERE oi.fulfillment = 'shipping' - ) - AND ( - p_brand_id IS NULL - OR o.brand_id = p_brand_id - ) - ORDER BY o.created_at DESC; - - RETURN COALESCE(v_result, '[]'::JSONB); -END; -$$; - -NOTIFY pgrst, 'reload schema'; - --- ─────────────────────────────────────────── --- 041_payment_settings.sql --- ─────────────────────────────────────────── --- Migration 041: Payment Settings --- Creates payment_settings table and RPCs for provider configuration. - --- ── 1. payment_settings table ───────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.payment_settings ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, - provider TEXT, -- stripe | square | manual - stripe_publishable_key TEXT, - stripe_secret_key TEXT, - square_access_token TEXT, - square_location_id TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE(brand_id) -); - --- ── 2. RLS ────────────────────────────────────────────────────────────────── -ALTER TABLE public.payment_settings ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Brand admin can read payment_settings" ON public.payment_settings; -CREATE POLICY "Brand admin can read payment_settings" - ON public.payment_settings FOR SELECT TO authenticated - USING ( - brand_id IN ( - SELECT brand_id FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'brand_admin' - ) - ); - -DROP POLICY IF EXISTS "Platform admin can read payment_settings" ON public.payment_settings; -CREATE POLICY "Platform admin can read payment_settings" - ON public.payment_settings FOR SELECT TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM admin_users - WHERE admin_users.user_id = auth.uid() - AND admin_users.role = 'platform_admin' - ) - ); - --- Writes go through SECURITY DEFINER helpers only. - --- ── 3. get_payment_settings ────────────────────────────────────────────────── -CREATE OR REPLACE FUNCTION public.get_payment_settings(p_brand_id UUID) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_result JSONB; -BEGIN - SELECT jsonb_build_object( - 'id', id, - 'brand_id', brand_id, - 'provider', provider, - 'stripe_publishable_key', stripe_publishable_key, - 'stripe_secret_key', stripe_secret_key, - 'square_access_token', square_access_token, - 'square_location_id', square_location_id, - 'updated_at', updated_at::TEXT - ) INTO v_result - FROM payment_settings - WHERE brand_id = p_brand_id; - - RETURN v_result; -END; -$$; - --- ── 4. upsert_payment_settings ──────────────────────────────────────────────── -CREATE OR REPLACE FUNCTION public.upsert_payment_settings( - p_brand_id UUID, - p_provider TEXT, - p_stripe_publishable_key TEXT DEFAULT NULL, - p_stripe_secret_key TEXT DEFAULT NULL, - p_square_access_token TEXT DEFAULT NULL, - p_square_location_id TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -BEGIN - INSERT INTO payment_settings ( - brand_id, provider, - stripe_publishable_key, stripe_secret_key, - square_access_token, square_location_id - ) - VALUES ( - p_brand_id, p_provider, - p_stripe_publishable_key, p_stripe_secret_key, - p_square_access_token, p_square_location_id - ) - ON CONFLICT (brand_id) DO UPDATE SET - provider = EXCLUDED.provider, - stripe_publishable_key = EXCLUDED.stripe_publishable_key, - stripe_secret_key = EXCLUDED.stripe_secret_key, - square_access_token = EXCLUDED.square_access_token, - square_location_id = EXCLUDED.square_location_id, - updated_at = now(); - - RETURN jsonb_build_object('success', true); -END; -$$; - -NOTIFY pgrst, 'reload schema'; - --- ─────────────────────────────────────────── --- 042_product_import_rpc.sql --- ─────────────────────────────────────────── --- Migration 042: Product Import RPC + upsert_product --- Idempotent: CREATE OR REPLACE FUNCTION - --- ── 1. upsert_product ───────────────────────────────────────────────────────── --- Upserts a single product by name (within brand). Returns the product id. --- Used by the CSV import tool to create or update products in bulk. - -CREATE OR REPLACE FUNCTION public.upsert_product( - p_brand_id UUID, - p_name TEXT, - p_price NUMERIC, - p_type TEXT, - p_description TEXT DEFAULT '', - p_active BOOLEAN DEFAULT true, - p_image_url TEXT DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_product_id UUID; - v_is_update BOOLEAN := false; -BEGIN - -- Check if product with same name exists in this brand - SELECT id INTO v_product_id - FROM products - WHERE brand_id = p_brand_id AND name = p_name - LIMIT 1; - - IF v_product_id IS NOT NULL THEN - -- Update existing - UPDATE products SET - name = p_name, - description = p_description, - price = p_price, - type = p_type, - active = p_active, - image_url = COALESCE(p_image_url, image_url), - updated_at = now() - WHERE id = v_product_id - RETURNING id INTO v_product_id; - v_is_update := true; - ELSE - -- Insert new - INSERT INTO products (brand_id, name, description, price, type, active, image_url) - VALUES (p_brand_id, p_name, p_description, p_price, p_type, p_active, p_image_url) - RETURNING id INTO v_product_id; - END IF; - - RETURN jsonb_build_object( - 'id', v_product_id, - 'is_update', v_is_update, - 'name', p_name - ); -END; -$$; - --- ── 2. bulk_upsert_products ────────────────────────────────────────────────── --- Takes an array of product records and upserts them all within a brand. --- Returns a summary: { created, updated, errors }. - -CREATE OR REPLACE FUNCTION public.bulk_upsert_products( - p_brand_id UUID, - p_products JSONB -- array of { name, description, price, type, active, image_url } -) -RETURNS JSONB -LANGUAGE plpgsql SECURITY DEFINER SET search_path = public -AS $$ -DECLARE - v_entry JSONB; - v_result JSONB := '{"created": 0, "updated": 0, "errors": []}'::JSONB; - v_name TEXT; - v_desc TEXT; - v_price NUMERIC; - v_type TEXT; - v_active BOOLEAN; - v_img TEXT; - v_was_new BOOLEAN; -BEGIN - FOR v_entry IN SELECT * FROM jsonb_array_elements(p_products) - LOOP - BEGIN - v_name := nullif(v_entry->>'name', ''); - v_desc := coalesce(nullif(v_entry->>'description', ''), ''); - v_price := nullif((v_entry->>'price')::TEXT, '')::NUMERIC; - v_type := nullif(v_entry->>'type', ''); - v_active := coalesce((v_entry->>'active')::BOOLEAN, true); - v_img := nullif(v_entry->>'image_url', ''); - - IF v_name IS NULL OR v_name = '' THEN - RAISE EXCEPTION 'Product name is required'; - END IF; - - IF v_price IS NULL OR v_price < 0 THEN - RAISE EXCEPTION 'Invalid price for product: %', v_name; - END IF; - - IF v_type NOT IN ('Pickup', 'Shipping', 'Pickup & Shipping') THEN - RAISE EXCEPTION 'Invalid type for product: %. Must be Pickup, Shipping, or Pickup & Shipping', v_name; - END IF; - - v_was_new := NOT EXISTS ( - SELECT 1 FROM products WHERE brand_id = p_brand_id AND name = v_name - ); - - PERFORM upsert_product(p_brand_id, v_name, v_desc, v_price, v_type, v_active, v_img); - - IF v_was_new THEN - v_result := jsonb_set(v_result, '{created}', - to_jsonb((v_result->>'created')::INTEGER + 1)); - ELSE - v_result := jsonb_set(v_result, '{updated}', - to_jsonb((v_result->>'updated')::INTEGER + 1)); - END IF; - - EXCEPTION WHEN OTHERS THEN - v_result := jsonb_set( - v_result, '{errors}', - v_result->'errors' || jsonb_build_array( - jsonb_build_object('product', v_name, 'error', SQLERRM) - ) - ); - END; - END LOOP; - - RETURN v_result; -END; -$$; - -NOTIFY pgrst, 'reload schema'; - diff --git a/supabase/migrations/.archived/XXX_blog_tables.sql b/supabase/migrations/.archived/XXX_blog_tables.sql deleted file mode 100644 index bb912a5..0000000 --- a/supabase/migrations/.archived/XXX_blog_tables.sql +++ /dev/null @@ -1,77 +0,0 @@ --- Blog and Newsletter Tables --- Migration for Route Commerce - -CREATE TABLE IF NOT EXISTS blog_posts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - slug TEXT UNIQUE NOT NULL, - title TEXT NOT NULL, - excerpt TEXT, - content TEXT, - featured_image TEXT, - author_name TEXT NOT NULL, - author_avatar TEXT, - category TEXT DEFAULT 'General', - tags TEXT[], - published_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - published BOOLEAN DEFAULT FALSE -); - -CREATE TABLE IF NOT EXISTS blog_resources ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - title TEXT NOT NULL, - description TEXT, - type TEXT DEFAULT 'guide' CHECK (type IN ('guide', 'case_study', 'webinar', 'template', 'checklist')), - url TEXT, - thumbnail TEXT, - downloads INTEGER DEFAULT 0, - featured BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS newsletter_subscribers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT UNIQUE NOT NULL, - subscribed_at TIMESTAMPTZ DEFAULT NOW(), - status TEXT DEFAULT 'active' CHECK (status IN ('active', 'unsubscribed', 'bounced')), - source TEXT, - unsubscribed_at TIMESTAMPTZ -); - --- Indexes -CREATE INDEX IF NOT EXISTS idx_blog_slug ON blog_posts(slug); -CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published); -CREATE INDEX IF NOT EXISTS idx_blog_category ON blog_posts(category); -CREATE INDEX IF NOT EXISTS idx_newsletter_email ON newsletter_subscribers(email); - --- Grant permissions -GRANT SELECT, INSERT ON blog_posts TO anon; -GRANT SELECT, INSERT ON blog_resources TO anon; -GRANT SELECT, INSERT ON newsletter_subscribers TO anon; -GRANT ALL ON blog_posts TO authenticated; -GRANT ALL ON blog_resources TO authenticated; -GRANT ALL ON newsletter_subscribers TO authenticated; -GRANT ALL ON blog_posts TO service_role; -GRANT ALL ON blog_resources TO service_role; -GRANT ALL ON newsletter_subscribers TO service_role; - --- Seed blog posts -INSERT INTO blog_posts (slug, title, excerpt, content, author_name, category, published_at, published) VALUES - ('getting-started-with-route-commerce', 'Getting Started with Route Commerce', 'Learn how to set up your wholesale business on Route Commerce in under 10 minutes.', '## Getting Started - -Welcome to Route Commerce! This guide will walk you through setting up your wholesale operation...', 'Team Route Commerce', 'Guides', NOW(), TRUE), - ('maximize-produce-profitability', '5 Tips to Maximize Your Produce Profitability', 'Strategic pricing and inventory management can significantly boost your bottom line.', '## Introduction - -Running a profitable produce operation requires more than just quality products...', 'Sarah Johnson', 'Tips', NOW(), TRUE), - ('customer-communication-best-practices', 'Best Practices for Customer Communication', 'Keep your customers informed and engaged with these communication strategies.', '## Why Communication Matters - -Clear, timely communication builds trust and reduces missed pickups...', 'Marcus Chen', 'Marketing', NOW(), TRUE) -ON CONFLICT DO NOTHING; - --- Seed resources -INSERT INTO blog_resources (title, description, type, downloads, featured) VALUES - ('Wholesale Pricing Guide', 'Complete guide to setting profitable wholesale prices', 'guide', 342, TRUE), - ('Order Management Checklist', 'Step-by-step checklist for order fulfillment', 'checklist', 256, FALSE), - ('Customer Email Templates', 'Pre-written email templates for common scenarios', 'template', 189, FALSE) -ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/supabase/migrations/.archived/XXX_launch_checklist.sql b/supabase/migrations/.archived/XXX_launch_checklist.sql deleted file mode 100644 index c65afa4..0000000 --- a/supabase/migrations/.archived/XXX_launch_checklist.sql +++ /dev/null @@ -1,22 +0,0 @@ --- Launch Checklist Progress Table --- Track user's launch preparation progress - -CREATE TABLE IF NOT EXISTS launch_checklist_progress ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id TEXT NOT NULL, - item_id TEXT NOT NULL, - completed BOOLEAN DEFAULT FALSE, - completed_at TIMESTAMPTZ, - notes TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(user_id, item_id) -); - --- Indexes -CREATE INDEX IF NOT EXISTS idx_checklist_user ON launch_checklist_progress(user_id); -CREATE INDEX IF NOT EXISTS idx_checklist_completed ON launch_checklist_progress(completed) WHERE completed = TRUE; - --- Grant permissions -GRANT SELECT, INSERT, UPDATE ON launch_checklist_progress TO authenticated; -GRANT ALL ON launch_checklist_progress TO service_role; \ No newline at end of file diff --git a/supabase/migrations/.archived/XXX_roadmap_tables.sql b/supabase/migrations/.archived/XXX_roadmap_tables.sql deleted file mode 100644 index b9ea10b..0000000 --- a/supabase/migrations/.archived/XXX_roadmap_tables.sql +++ /dev/null @@ -1,45 +0,0 @@ --- Roadmap Tables for Feature Requests and Voting --- Migration for Route Commerce - -CREATE TABLE IF NOT EXISTS roadmap_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - title TEXT NOT NULL, - description TEXT, - status TEXT DEFAULT 'planned' CHECK (status IN ('planned', 'in_progress', 'shipped')), - category TEXT, - upvotes INTEGER DEFAULT 0, - created_by TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS roadmap_votes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - item_id UUID REFERENCES roadmap_items(id) ON DELETE CASCADE, - visitor_id TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(item_id, visitor_id) -); - --- Indexes -CREATE INDEX IF NOT EXISTS idx_roadmap_status ON roadmap_items(status); -CREATE INDEX IF NOT EXISTS idx_roadmap_category ON roadmap_items(category); -CREATE INDEX IF NOT EXISTS idx_roadmap_upvotes ON roadmap_items(upvotes DESC); -CREATE INDEX IF NOT EXISTS idx_roadmap_votes_item ON roadmap_votes(item_id); - --- Grant permissions -GRANT SELECT ON roadmap_items TO anon; -GRANT SELECT, INSERT ON roadmap_votes TO anon; -GRANT ALL ON roadmap_items TO authenticated; -GRANT ALL ON roadmap_votes TO authenticated; -GRANT ALL ON roadmap_items TO service_role; -GRANT ALL ON roadmap_votes TO service_role; - --- Seed some initial items -INSERT INTO roadmap_items (title, description, status, category, upvotes) VALUES - ('Mobile App (iOS & Android)', 'Native apps for field workers and delivery drivers', 'in_progress', 'Mobile', 234), - ('SMS Campaigns', 'Text message marketing and notifications', 'planned', 'Communication', 98), - ('Route Optimization', 'AI-powered route planning for deliveries', 'planned', 'Logistics', 167), - ('Customer Loyalty Program', 'Points, rewards, and referral tracking', 'planned', 'Marketing', 112), - ('POS Integration (Clover, Toast)', 'Additional POS system integrations', 'planned', 'Integrations', 76) -ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/supabase/migrations/.archived/XXX_waitlist_table.sql b/supabase/migrations/.archived/XXX_waitlist_table.sql deleted file mode 100644 index f29557f..0000000 --- a/supabase/migrations/.archived/XXX_waitlist_table.sql +++ /dev/null @@ -1,30 +0,0 @@ --- Waitlist and Early Access Signup System --- Migration for Route Commerce - -CREATE TABLE IF NOT EXISTS waitlist ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT UNIQUE NOT NULL, - name TEXT, - referral_source TEXT, - referred_by TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'converted', 'archived')) -); - --- Index for email lookups -CREATE INDEX IF NOT EXISTS idx_waitlist_email ON waitlist(email); -CREATE INDEX IF NOT EXISTS idx_waitlist_status ON waitlist(status); -CREATE INDEX IF NOT EXISTS idx_waitlist_created ON waitlist(created_at DESC); - --- Grant permissions -GRANT SELECT, INSERT ON waitlist TO anon; -GRANT SELECT, INSERT ON waitlist TO authenticated; -GRANT SELECT, INSERT ON waitlist TO service_role; - --- Comment on table -COMMENT ON TABLE waitlist IS 'Early access signup for Route Commerce platform'; -COMMENT ON COLUMN waitlist.email IS 'Unique email address'; -COMMENT ON COLUMN waitlist.name IS 'Optional full name'; -COMMENT ON COLUMN waitlist.referral_source IS 'How they heard about us'; -COMMENT ON COLUMN waitlist.referred_by IS 'Email of person who referred them'; -COMMENT ON COLUMN waitlist.status IS 'pending, converted (signed up), archived'; \ No newline at end of file diff --git a/supabase/migrations/.archived/_000_auth_schema.sql b/supabase/migrations/.archived/_000_auth_schema.sql deleted file mode 100644 index e1b4f3a..0000000 --- a/supabase/migrations/.archived/_000_auth_schema.sql +++ /dev/null @@ -1,111 +0,0 @@ --- _000_auth_schema.sql --- --- Local stand-in for the Supabase `auth` schema. Supabase ships a --- built-in `auth.users` table and `auth.uid()` / `auth.role()` functions --- that SECURITY DEFINER RPCs read. For a direct-Postgres deployment --- (no Supabase platform), we recreate the minimum surface those RPCs --- depend on, and use Postgres session GUCs to thread the caller's --- identity from the application layer. --- --- Production auth model: --- - Auth.js v5 manages the user session (Google OAuth in /login, --- `dev_session` cookie for the demo flow). --- - Each `pg` connection that calls a SECURITY DEFINER RPC first --- runs `SELECT set_config('app.current_user_id', $1, true)` so --- `auth.uid()` returns the correct value inside the RPC. --- - The app-level middleware (`getAdminUser()`) is the primary --- authorization gate; the RPCs are a defense-in-depth check that --- the caller is in `admin_users` and not a foreign brand. --- --- This file is local-only and should NOT be pushed to a Supabase-hosted --- DB (the schema already exists there). - --- ═══════════════════════════════════════════════════════════════════════════ --- 1. Schema --- ═══════════════════════════════════════════════════════════════════════════ - -CREATE SCHEMA IF NOT EXISTS auth; - --- ═══════════════════════════════════════════════════════════════════════════ --- 2. auth.users — minimal Supabase-compatible shape --- ═══════════════════════════════════════════════════════════════════════════ --- --- Columns the migrations actually read: --- id, email, raw_user_meta_data, raw_app_meta_data, encrypted_password --- (Supabase's full schema has ~30 columns; the SECURITY DEFINER --- functions in this codebase only need the four above.) - -CREATE TABLE IF NOT EXISTS auth.users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT UNIQUE, - raw_user_meta_data JSONB DEFAULT '{}'::jsonb, - raw_app_meta_data JSONB DEFAULT '{}'::jsonb, - encrypted_password TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Mirror Supabase's `auth.identities` for the `update_admin_user` --- trigger that writes back to `auth.users`. Most migrations don't --- touch it; kept here so a stray FK / view doesn't blow up. -CREATE TABLE IF NOT EXISTS auth.identities ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - provider TEXT NOT NULL, - provider_id TEXT NOT NULL, - identity_data JSONB DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (provider, provider_id) -); - -CREATE INDEX IF NOT EXISTS auth_identities_user_id_idx - ON auth.identities (user_id); - --- ═══════════════════════════════════════════════════════════════════════════ --- 3. auth.uid() / auth.role() — session helpers --- ═══════════════════════════════════════════════════════════════════════════ --- --- These mirror Supabase's signature. They read Postgres session GUCs --- (`app.current_user_id` and `app.current_user_role`) that the --- application layer sets before calling SECURITY DEFINER RPCs: --- --- await client.query("SELECT set_config('app.current_user_id', $1, true)", [userId]); --- await client.rpc("get_admin_users", { p_brand_id }); --- --- The `true` argument makes the setting transaction-local, so it --- auto-resets at COMMIT / ROLLBACK — no leakage across pooled --- connections. - -CREATE OR REPLACE FUNCTION auth.uid() -RETURNS UUID -LANGUAGE sql -STABLE -AS $$ - SELECT NULLIF(current_setting('app.current_user_id', true), '')::UUID; -$$; - -CREATE OR REPLACE FUNCTION auth.role() -RETURNS TEXT -LANGUAGE sql -STABLE -AS $$ - SELECT COALESCE(NULLIF(current_setting('app.current_user_role', true), ''), 'anon'); -$$; - --- ═══════════════════════════════════════════════════════════════════════════ --- 4. notify_pgrst — stub for PostgREST schema-reload signaling --- ═══════════════════════════════════════════════════════════════════════════ --- --- Some migrations `NOTIFY pgrst, 'reload schema'` to tell PostgREST to --- refresh its cache. In a direct-pg deployment, no PostgREST runs, so --- this is a no-op. - -CREATE OR REPLACE FUNCTION public.notify_pgrst() -RETURNS void -LANGUAGE plpgsql -AS $$ -BEGIN - -- no-op: no PostgREST to notify in a direct-pg deployment - NULL; -END; -$$; diff --git a/supabase/push-migrations.js b/supabase/push-migrations.js deleted file mode 100644 index b237b9b..0000000 --- a/supabase/push-migrations.js +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env node -/** - * push-migrations.js - * - * Pushes migration files to the linked Supabase project. - * Uses the Supabase CLI (supabase db query --linked --file) if the project - * is linked (after `supabase login` + `supabase link --project-ref `), - * otherwise falls back to direct PostgreSQL connection via `pg`. - * - * Usage: - * node supabase/push-migrations.js # push all migration files - * node supabase/push-migrations.js 148 # push only migrations matching 148_*.sql - * - * Prerequisites for CLI mode (preferred, works even without direct 5432 access): - * supabase login - * supabase link --project-ref wnzkhezyhnfzhkhiflrp - * - * Prerequisites for direct PG mode: - * .env.local with NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY - * (pg and dotenv are in devDependencies) - */ - -const { Client } = require("pg"); -const fs = require("fs"); -const path = require("path"); -const { execSync } = require("child_process"); - -// Load .env.local for direct PG mode -try { - const dotenv = require("dotenv"); - dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); -} catch { - // dotenv not available — CLI mode only -} - -const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; -const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - -async function pushWithCli(files) { - console.log("Using Supabase CLI (supabase db query --linked --file)...\n"); - for (const f of files) { - const filePath = `supabase/migrations/${f}`; - process.stdout.write(` Applying ${f}... `); - try { - execSync( - `supabase db query --linked --file "${filePath}"`, - { stdio: "inherit", cwd: path.resolve(__dirname, "..") } - ); - console.log("✓"); - } catch (err) { - console.log("✗"); - // execSync with inherit already printed stderr/stdout; surface a short message - const msg = (err && err.message) ? err.message.replace(/\n/g, " ").slice(0, 200) : ""; - if (msg) console.error(" " + msg); - return false; - } - } - return true; -} - -async function pushWithPg(sql, migrationFile) { - if (!supabaseUrl || !serviceRoleKey) { - console.error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local"); - return false; - } - - // Derive direct PG host from Supabase URL - const projectRef = supabaseUrl.replace("https://", "").split(".")[0]; - const pgHost = `db.${projectRef}.supabase.co`; - - const client = new Client({ - host: pgHost, - port: 5432, - database: "postgres", - user: "postgres", - password: serviceRoleKey, - ssl: { rejectUnauthorized: false }, - }); - - try { - await client.connect(); - await client.query(sql); - console.log(` ✓ Applied: ${migrationFile}`); - return true; - } catch (err) { - const cleanMsg = err.message.replace(/\x1b\[[0-9;]*m/g, ""); - console.error(` ERROR: ${cleanMsg.slice(0, 300)}`); - return false; - } finally { - await client.end(); - } -} - -async function main() { - const targetMigration = process.argv[2]; - - let files = fs.readdirSync(path.resolve(__dirname, "migrations")) - .filter((f) => f.endsWith(".sql")) - .sort(); - - if (targetMigration) { - files = files.filter((f) => f.startsWith(targetMigration + "_")); - if (files.length === 0) { - console.error(`No migration found matching: ${targetMigration}_*`); - process.exit(1); - } - } - - console.log(`Found ${files.length} migration file(s) to push:`); - for (const f of files) console.log(` ${f}`); - console.log(""); - - let ok = false; - - // Try CLI first if supabase is linked. - // Legacy link: .supabase/config.toml at project root - // Modern link (post `supabase link`): supabase/.temp/project-ref - let hasSupabaseCli = false; - try { - hasSupabaseCli = !!execSync("which supabase", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); - } catch { - hasSupabaseCli = false; - } - const hasLegacyLink = fs.existsSync(path.resolve(__dirname, "../.supabase/config.toml")); - const hasModernLink = fs.existsSync(path.resolve(__dirname, ".temp/project-ref")); - const isLinked = hasLegacyLink || hasModernLink; - - if (hasSupabaseCli && isLinked) { - console.log("Supabase CLI detected and project is linked.\n"); - ok = await pushWithCli(files); - if (ok) { - console.log(`\nDone. ${files.length} migration(s) pushed via Supabase CLI.`); - return; - } - console.log("CLI mode failed, falling back to direct PG connection...\n"); - } - - // Direct PG fallback - for (const file of files) { - const sql = fs.readFileSync(path.resolve(__dirname, "migrations", file), "utf8"); - process.stdout.write(`Pushing ${file}... `); - const fileOk = await pushWithPg(sql, file); - if (!fileOk) { - console.error(`\nMigration failed: ${file}`); - process.exit(1); - } - } - - console.log(`\nDone. ${files.length} migration(s) pushed via direct PG.`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); \ No newline at end of file diff --git a/vercel.json b/vercel.json index 07720d9..0098ef8 100644 --- a/vercel.json +++ b/vercel.json @@ -50,7 +50,7 @@ { "key": "X-DNS-Prefetch-Control", "value": "on" }, { "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" }, { "key": "X-Permitted-Cross-Domain-Policies", "value": "none" }, - { "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.supabase.co https://*.resend.com https://*.stripe.com" } + { "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.resend.com https://*.stripe.com" } ] } ], diff --git a/vitest.config.ts b/vitest.config.ts index ee1af17..7dea38b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ "src/lib/offline/**/*.test.ts", ], exclude: ["node_modules", ".next", "tests/e2e/**", "tests/login/**", "tests/smoke.spec.ts"], - // Supabase REST, Auth.js v5, and Next.js `cookies()` / `headers()` are + // Better Auth (Neon Auth) and Next.js `cookies()` / `headers()` are // stubbed in each test — keep the timeout generous. testTimeout: 15_000, },