From 2de32b0c663f46727dcc4fc8144daacbb543ac66 Mon Sep 17 00:00:00 2001 From: default Date: Fri, 5 Jun 2026 00:38:29 +0000 Subject: [PATCH 1/8] Add rocket emoji to tagline --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f085eb6..423fe90 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Route Commerce -A multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands manage products, stops, orders, and wholesale customers from a single admin dashboard. +A multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands manage products, stops, orders, and wholesale customers from a single admin dashboard. πŸš€ ## What It Does -- 2.43.0 From 988310fd50b807490b153b3f4f36110840d5f7cf Mon Sep 17 00:00:00 2001 From: tyler Date: Thu, 4 Jun 2026 18:42:58 -0600 Subject: [PATCH 2/8] Add Gitea Actions deploy workflow --- .gitea/workflows/deploy.yml | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .gitea/workflows/deploy.yml diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..89bcd7f --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,57 @@ +name: Deploy to route.crispygoat.com + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + env: + NODE_ENV: production + + - name: Deploy + run: | + APP_DIR=/home/tyler/route-commerce + mkdir -p $APP_DIR + + # Copy build output and required files + rsync -a --delete .next/ $APP_DIR/.next/ + rsync -a --delete public/ $APP_DIR/public/ + cp package.json package-lock.json $APP_DIR/ + cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true + + # Install production deps only + cd $APP_DIR + npm ci --omit=dev + + # Copy env if not already there + if [ ! -f $APP_DIR/.env.production ]; then + echo "WARNING: No .env.production found at $APP_DIR β€” app may not start correctly" + fi + + # Start or restart PM2 process + if pm2 describe route-commerce > /dev/null 2>&1; then + pm2 restart route-commerce + else + pm2 start npm --name route-commerce -- start -- -p 3100 + pm2 save + fi + + echo "Deployed successfully" -- 2.43.0 From 8ad8dbb25b36900222c0c2aff7b43a8b89a3f49e Mon Sep 17 00:00:00 2001 From: tyler Date: Thu, 4 Jun 2026 18:47:46 -0600 Subject: [PATCH 3/8] Remove npm cache from workflow (local runner) --- .gitea/workflows/deploy.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 89bcd7f..99aec05 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -16,7 +16,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' - name: Install dependencies run: npm ci -- 2.43.0 From fddb9178c07d85022c01a40ffdc12119b301de4d Mon Sep 17 00:00:00 2001 From: tyler Date: Thu, 4 Jun 2026 18:48:21 -0600 Subject: [PATCH 4/8] Use npm install instead of npm ci (no lockfile) --- .gitea/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 99aec05..0b574a7 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -18,7 +18,7 @@ jobs: node-version: '22' - name: Install dependencies - run: npm ci + run: npm install - name: Build run: npm run build @@ -38,7 +38,7 @@ jobs: # Install production deps only cd $APP_DIR - npm ci --omit=dev + npm install --omit=dev # Copy env if not already there if [ ! -f $APP_DIR/.env.production ]; then -- 2.43.0 From beac3e48f1ed5e03723e774461d9ef78bed1837b Mon Sep 17 00:00:00 2001 From: tyler Date: Thu, 4 Jun 2026 18:52:02 -0600 Subject: [PATCH 5/8] Load .env.production from server before build --- .gitea/workflows/deploy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 0b574a7..db21714 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -20,6 +20,9 @@ jobs: - name: Install dependencies run: npm install + - name: Load env + run: cp /home/tyler/route-commerce/.env.production .env.production + - name: Build run: npm run build env: -- 2.43.0 From e8f2d76637085d6a9e70151460b803cad695604b Mon Sep 17 00:00:00 2001 From: tyler Date: Thu, 4 Jun 2026 18:53:11 -0600 Subject: [PATCH 6/8] Fix workflow: use actual secrets, fix env file writing --- .gitea/workflows/deploy.yml | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index db21714..02712cc 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -20,34 +20,44 @@ jobs: - name: Install dependencies run: npm install - - name: Load env - run: cp /home/tyler/route-commerce/.env.production .env.production - - name: Build run: npm run build env: NODE_ENV: production + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }} - name: Deploy + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }} run: | APP_DIR=/home/tyler/route-commerce mkdir -p $APP_DIR + # Write env file from secrets + printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL" > $APP_DIR/.env.production + printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY" >> $APP_DIR/.env.production + printf "SUPABASE_SERVICE_ROLE_KEY=%s\n" "$SUPABASE_SERVICE_ROLE_KEY" >> $APP_DIR/.env.production + printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY" >> $APP_DIR/.env.production + printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL" >> $APP_DIR/.env.production + # Copy build output and required files rsync -a --delete .next/ $APP_DIR/.next/ rsync -a --delete public/ $APP_DIR/public/ - cp package.json package-lock.json $APP_DIR/ + cp package.json $APP_DIR/ cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true # Install production deps only cd $APP_DIR npm install --omit=dev - # Copy env if not already there - if [ ! -f $APP_DIR/.env.production ]; then - echo "WARNING: No .env.production found at $APP_DIR β€” app may not start correctly" - fi - # Start or restart PM2 process if pm2 describe route-commerce > /dev/null 2>&1; then pm2 restart route-commerce -- 2.43.0 From 367a5625f33f613df207cbbc054f33d3a3955d2a Mon Sep 17 00:00:00 2001 From: default Date: Fri, 5 Jun 2026 01:19:10 +0000 Subject: [PATCH 7/8] Add self-hosted Postgres docker-compose - docker-compose.yml: Postgres 16 Alpine with named volume, healthcheck - .env.example: POSTGRES_* and DATABASE_URL template - .gitignore: exclude db_data/ volume Starting fresh, no data migration. App still wired to Supabase; DB is ready for migrations to be applied. --- .env.example | 15 +++++++++++++++ .gitignore | 4 +++- docker-compose.yml | 25 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1448d7c --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# --- Self-hosted Postgres (Docker) --- +POSTGRES_USER=routecommerce +POSTGRES_PASSWORD=change-me-strong-password +POSTGRES_DB=route_commerce +# Used by the app and push-migrations.js to talk to the local DB +DATABASE_URL=postgresql://routecommerce:change-me-strong-password@127.0.0.1:5432/route_commerce?schema=public + +# --- Supabase (legacy, can be removed once app is rewired) --- +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# --- App secrets --- +MINIMAX_API_KEY= +MINIMAX_BASE_URL= diff --git a/.gitignore b/.gitignore index 81d5216..c49893a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,6 @@ supabase/.temp/ # IDE / local config .mcp.json -.env* + +# Docker / self-hosted Postgres +db_data/ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..64450d4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + db: + image: postgres:16-alpine + container_name: route_commerce_db + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - db_data:/var/lib/postgresql/data + ports: + - "127.0.0.1:5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + db_data: + driver: local -- 2.43.0 From 456b5b137584ffc1580ee17044d84cd220fbc518 Mon Sep 17 00:00:00 2001 From: default Date: Fri, 5 Jun 2026 02:33:05 +0000 Subject: [PATCH 8/8] Add MinIO storage + replace Supabase Storage with S3 SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New: src/lib/storage.ts β€” S3-compatible client, uploadFile/deleteFile/listFiles/publicUrl helpers, bucket constants - New: docker-compose adds minio + minio_init services - Replaced Supabase fetch PUTs in: - src/actions/brand-settings.ts (3 uploaders) - src/actions/products/upload-image.ts - src/actions/communications/import-contacts.ts - src/app/api/water-photo-upload/route.ts - Replaced hardcoded Supabase URLs in: - src/components/storefront/TuxedoVideoHero.tsx - src/components/time-tracking/TimeTrackingFieldClient.tsx - src/app/tuxedo/about/page.tsx - src/lib/email-service.ts (4 occurrences) - Added @aws-sdk/client-s3 + @aws-sdk/s3-request-presigner - .env.example adds MinIO + storage vars - Migration cleanup: deleted BUNDLE_018_042, 4 XXX drafts, 087/145/099-contact storage migrations - Migration patches: 006 STATICβ†’STABLE, 135 param reordering - Preflight: added pgcrypto extension, removed storage stub - Verified: MinIO upload/list/delete round-trip works against local instance --- .env.example | 31 +- 019e9554-37c8-7752-a9d5-0264371602a9/plan.md | 250 + Tuxedo_Corn_2026_Tour_Schedule-3.xlsx | Bin 0 -> 34276 bytes docker-compose.yml | 58 + middleware.ts | 50 +- package.json | 5 + src/actions/admin/force-login.ts | 84 +- src/actions/admin/profile.ts | 31 + src/actions/brand-settings.ts | 102 +- src/actions/communications/import-contacts.ts | 92 +- src/actions/login.ts | 57 +- src/actions/products/upload-image.ts | 47 +- src/actions/wholesale-auth.ts | 153 +- src/app/admin/me/AdminMeClient.tsx | 23 +- src/app/api/auth/[...all]/route.ts | 4 + src/app/api/water-photo-upload/route.ts | 43 +- src/app/indian-river-direct/stops/page.tsx | 1 + src/app/logout/page.tsx | 22 +- src/app/reset-password/page.tsx | 15 +- src/app/tuxedo/about/page.tsx | 7 +- src/components/admin/AdminHeader.tsx | 4 +- src/components/admin/AdminSidebar.tsx | 4 +- src/components/storefront/TuxedoVideoHero.tsx | 10 +- .../time-tracking/TimeTrackingFieldClient.tsx | 7 +- src/lib/admin-permissions.ts | 70 +- src/lib/auth-client.ts | 9 + src/lib/auth.ts | 47 + src/lib/email-service.ts | 14 +- src/lib/storage.ts | 88 + src/lib/supabase/server.ts | 25 - .../000_preflight_supabase_compat.sql | 71 + .../migrations/006_water_log_rpcs_fixed.sql | 12 +- .../migrations/087_brand_logos_bucket.sql | 77 - .../migrations/099_contact_imports_bucket.sql | 36 - .../migrations/135_email_automation_rpcs.sql | 2 +- .../145_create_product_images_bucket.sql | 17 - .../migrations/200_better_auth_tables.sql | 61 + supabase/migrations/BUNDLE_018_042.sql | 4778 ----------------- supabase/migrations/XXX_blog_tables.sql | 77 - supabase/migrations/XXX_launch_checklist.sql | 22 - supabase/migrations/XXX_roadmap_tables.sql | 45 - supabase/migrations/XXX_waitlist_table.sql | 30 - 42 files changed, 973 insertions(+), 5608 deletions(-) create mode 100644 019e9554-37c8-7752-a9d5-0264371602a9/plan.md create mode 100644 Tuxedo_Corn_2026_Tour_Schedule-3.xlsx create mode 100644 src/actions/admin/profile.ts create mode 100644 src/app/api/auth/[...all]/route.ts create mode 100644 src/lib/auth-client.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/storage.ts delete mode 100644 src/lib/supabase/server.ts create mode 100644 supabase/migrations/000_preflight_supabase_compat.sql delete mode 100644 supabase/migrations/087_brand_logos_bucket.sql delete mode 100644 supabase/migrations/099_contact_imports_bucket.sql delete mode 100644 supabase/migrations/145_create_product_images_bucket.sql create mode 100644 supabase/migrations/200_better_auth_tables.sql delete mode 100644 supabase/migrations/BUNDLE_018_042.sql delete mode 100644 supabase/migrations/XXX_blog_tables.sql delete mode 100644 supabase/migrations/XXX_launch_checklist.sql delete mode 100644 supabase/migrations/XXX_roadmap_tables.sql delete mode 100644 supabase/migrations/XXX_waitlist_table.sql diff --git a/.env.example b/.env.example index 1448d7c..79008c4 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,33 @@ # --- Self-hosted Postgres (Docker) --- POSTGRES_USER=routecommerce -POSTGRES_PASSWORD=change-me-strong-password +POSTGRES_PASSWORD=lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM POSTGRES_DB=route_commerce # Used by the app and push-migrations.js to talk to the local DB -DATABASE_URL=postgresql://routecommerce:change-me-strong-password@127.0.0.1:5432/route_commerce?schema=public +DATABASE_URL=postgresql://routecommerce:lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM@127.0.0.1:5432/route_commerce?schema=public -# --- Supabase (legacy, can be removed once app is rewired) --- -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= -SUPABASE_SERVICE_ROLE_KEY= +# --- PostgREST (REST API β€” replaces Supabase REST) --- +# Server actions call `${NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/...` +# Point that URL at the local PostgREST container. Anon key can be anything +# non-empty since we handle auth at the app layer (Better Auth + dev_session). +NEXT_PUBLIC_SUPABASE_URL=http://localhost:3001 +NEXT_PUBLIC_SUPABASE_ANON_KEY=local-postgrest-anon-key +SUPABASE_SERVICE_ROLE_KEY=local-postgrest-service-key + +# --- MinIO (S3-compatible object storage β€” replaces Supabase Storage) --- +MINIO_ROOT_USER=routecommerce +MINIO_ROOT_PASSWORD=miniochangeme123 +STORAGE_ENDPOINT=http://localhost:9000 +STORAGE_REGION=us-east-1 +STORAGE_ACCESS_KEY=routecommerce +STORAGE_SECRET_KEY=miniochangeme123 +STORAGE_BUCKET_PREFIX= +# Public base URL for image rendering in +NEXT_PUBLIC_STORAGE_BASE_URL=http://localhost:9000 + +# --- Better Auth --- +BETTER_AUTH_SECRET=change-me-32-char-random-string-here-pls-32chars +BETTER_AUTH_URL=http://localhost:3000 +NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000 # --- App secrets --- MINIMAX_API_KEY= diff --git a/019e9554-37c8-7752-a9d5-0264371602a9/plan.md b/019e9554-37c8-7752-a9d5-0264371602a9/plan.md new file mode 100644 index 0000000..65827ff --- /dev/null +++ b/019e9554-37c8-7752-a9d5-0264371602a9/plan.md @@ -0,0 +1,250 @@ +# Self-Hosted Storage + Schema Plan + +## Context + +The user is migrating Route Commerce from Supabase to a self-hosted stack: +- **Already done in this branch (`feat/better-auth`)**: Better Auth (auth), Docker Postgres, PostgREST, env var rewiring, ~10 source files updated to drop Supabase JS client. +- **What's broken right now**: The website is missing images (Supabase Storage URLs 404 because Supabase is going away), and the local Postgres can't apply the 137 migrations because the **base schema is missing** (the initial tables β€” `brands`, `orders`, `products`, `stops`, `admin_users` β€” were created in Supabase directly and never exported as a migration). +- **User constraint**: "no band-aids" β€” proper self-hosted replacement, not runtime patches. +- **User constraint**: "no data migration" β€” start with fresh empty DB; users re-upload assets. + +## Approach + +Three independent workstreams, executed in order: + +1. **Capture the base schema** from the still-live Supabase project using `pg_dump --schema-only` (recommended over `supabase db pull`, which has a broken migration history). Apply the captured schema + existing 137 migrations to the local self-hosted Postgres. +2. **Stand up MinIO** in Docker as the S3-compatible object store. Wire it to the app via the AWS SDK. MinIO's URL structure is clean: `http://minio:9000//`, publicly readable per-bucket via bucket policy. +3. **Replace every Supabase Storage URL** in the codebase with MinIO URLs (one env var: `NEXT_PUBLIC_STORAGE_BASE_URL`). + +Storage buckets in use (from explore agent): +| Bucket | Purpose | Current state | +|---|---|---| +| `brand-logos` | Brand logo images | Hardcoded in 8+ files via env-var URL | +| `product-images` | Product photos | `80aa01da-ab4b-44f8-b6e7-700552457e18` (Supabase bucket UUID) | +| `contacts-imports` | CSV contact imports | `a1b2c3d4-…` (Supabase bucket UUID) | +| `videos` | Tuxedo video hero | Hardcoded Supabase URL in `TuxedoVideoHero.tsx` | +| `water-photos` (dynamic) | Water log photos | API route, bucket name in form field | + +## Implementation + +### Phase A β€” Capture base schema and apply to local Postgres + +**Why `pg_dump`, not `supabase db pull`**: The remote Supabase project's `supabase_migrations.schema_migrations` history is out of sync with the local files (that's the long error list the user got). `supabase db pull` requires that history to be in sync before it'll write a new initial migration. `pg_dump` reads the live catalog directly and ignores the migration history entirely. + +**Steps**: + +1. **Capture the schema** from the remote Supabase DB (run on the user's Mac where direct PG works β€” MEMORY.md confirms direct PG is blocked from this dev box, but their Mac can do it): + ```bash + pg_dump "postgresql://postgres:@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" \ + --schema-only --no-owner --no-privileges \ + --schema=public \ + -f supabase/captured_schema.sql + ``` + *Exclude `auth` and `storage` schemas* β€” we stub `auth` ourselves and use MinIO instead of Supabase Storage. Note: 185 SECURITY DEFINER functions reference `auth.uid()`; these will compile against the preflight stub but return NULL at runtime. Phase D addresses this. + +2. **Delete files that don't belong** in the local apply order: + - `BUNDLE_018_042.sql` β€” concatenated duplicate + - `XXX_blog_tables.sql`, `XXX_launch_checklist.sql`, `XXX_roadmap_tables.sql`, `XXX_waitlist_waitlist.sql` β€” drafts (XXX convention) + - `099_contact_imports_bucket.sql` (the bucket-creation one β€” keep the RPCs though) + - Rename to disambiguate any number collisions (no current collision, but defensive) + +3. **Patch the 4 broken migrations** identified by the explore agent (read the file first, then `search_replace`): + - `006_water_log_rpcs_fixed.sql`: replace `STATIC` with `STABLE` (6 occurrences) + - `087_brand_logos_bucket.sql`: convert `CREATE POLICY IF NOT EXISTS …` to `DROP POLICY IF EXISTS …; CREATE POLICY …;` (4 policies) + - `099_harvest_reach_segmentation.sql`: quote the `time` column references (matches the 148 patch) + - `135_email_automation_rpcs.sql`: reorder `enroll_abandoned_cart` params so defaults come last, or add a default to `p_next_email_at` + +4. **Update `000_preflight_supabase_compat.sql`** (already in the branch): + - Add `CREATE EXTENSION IF NOT EXISTS pgcrypto;` at the top + - Remove the `storage.buckets` / `storage.objects` stub (MinIO replaces it; 087/145 storage policies will be deleted in step 2) + +5. **Apply to local Postgres** (running on this dev box, port 5432): + ```bash + PGPASSWORD='lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM' \ + psql -h 127.0.0.1 -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f supabase/migrations/000_preflight_supabase_compat.sql + + PGPASSWORD='lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM' \ + psql -h 127.0.0.1 -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f supabase/captured_schema.sql + + PGPASSWORD='lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM' \ + for f in supabase/migrations/[0-9]*.sql; do + psql -h 127.0.0.1 -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -q -f "$f" || break + done + ``` + Expected: most migrations apply, some fail (drop those, log in plan). This is the test that the local Postgres is actually usable. + +### Phase B β€” MinIO for object storage + +**Add to `docker-compose.yml`** (new `minio` service + `minio_init` one-shot to create buckets via `mc`): + +```yaml + minio: + image: minio/minio:latest + container_name: route_commerce_minio + restart: unless-stopped + env_file: [.env] + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + ports: ["127.0.0.1:9000:9000", "127.0.0.1:9001:9001"] + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 5 + + minio_init: + image: minio/mc:latest + depends_on: + minio: { condition: service_healthy } + env_file: [.env] + entrypoint: ["/bin/sh", "-c"] + command: + - | + mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD} + for b in brand-logos product-images contacts-imports videos water-photos; do + mc mb --ignore-existing local/$${b} + mc anonymous set download local/$${b} + done + profiles: ["init"] + +volumes: + minio_data: { driver: local } +``` + +**Add to `.env.example`**: +``` +MINIO_ROOT_USER=routecommerce +MINIO_ROOT_PASSWORD=change-me-minio-root +NEXT_PUBLIC_STORAGE_BASE_URL=http://localhost:9000 +STORAGE_ENDPOINT=http://localhost:9000 +STORAGE_REGION=us-east-1 +STORAGE_ACCESS_KEY=routecommerce +STORAGE_SECRET_KEY=change-me-minio-root +STORAGE_BUCKET_PREFIX= +``` + +**Install AWS SDK** (MinIO is S3-compatible): +```bash +npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner +``` + +### Phase C β€” Replace Supabase Storage calls with MinIO + +**New server-side helper** `src/lib/storage.ts`: +- `s3Client` configured from env (uses `@aws-sdk/client-s3`) +- `uploadFile({ bucket, key, body, contentType })` β€” replaces `fetch(PUT .../storage/v1/object/{bucket}/{key})` patterns +- `deleteFile({ bucket, key })` +- `publicUrl(bucket, key)` β€” returns `${STORAGE_BASE_URL}/${bucket}/${key}` +- Bucket name constants exported as a single source of truth + +**Files to modify** (from explore agent): +- `src/actions/brand-settings.ts` β€” 8 `fetch` PUTs to `/storage/v1/object/brand-logos/...` +- `src/actions/products/upload-image.ts` β€” 3 calls to `product-images` bucket +- `src/actions/communications/import-contacts.ts` β€” `contacts-imports` bucket (PUT + LIST + RPC) +- `src/app/api/water-photo-upload/route.ts` β€” dynamic bucket +- `src/components/storefront/TuxedoVideoHero.tsx` β€” hardcoded `https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/...` for videos and brand-logos +- `src/components/time-tracking/TimeTrackingFieldClient.tsx` β€” same hardcoded URL +- `src/lib/email-service.ts` β€” 4 occurrences of `wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/...` +- `src/app/tuxedo/about/page.tsx` β€” same hardcoded URL + +For the **hardcoded Supabase URLs**: replace with `publicUrl(bucket, key)` or `${process.env.NEXT_PUBLIC_STORAGE_BASE_URL}/${bucket}/${key}`. + +**Remove**: +- The `MockStorageBuilder` in `src/lib/supabase.ts:193-223` (no longer needed) +- `SUPABASE_PAT` env var from `.env.example` (only used by water-photo-upload) + +### Phase D β€” Auth wiring (closes the loop) + +The 185 `auth.uid()` references in the captured schema will return NULL without intervention. Two options: + +- **Option 1 (recommended, matches existing pattern)**: Disable RLS on all public tables, rely on the SECURITY DEFINER RPCs (which the codebase already does, per CLAUDE.md). One-time SQL after `captured_schema.sql` applies: `DO $$ DECLARE r record; BEGIN FOR r IN SELECT tablename FROM pg_tables WHERE schemaname='public' LOOP EXECUTE 'ALTER TABLE public.' || quote_ident(r.tablename) || ' DISABLE ROW LEVEL SECURITY'; END LOOP; END $$;` +- **Option 2**: Wire PostgREST to set `request.jwt.claim.sub` from the Better Auth session cookie. More complex; deferred unless RLS proves necessary. + +**Recommend Option 1** for now β€” it's consistent with the existing "brand scoping in server actions" pattern, and the SECURITY DEFINER functions still work because they execute with the function owner's privileges (no RLS blocking). + +### Phase E β€” Verification + +End-to-end test sequence (no more "trust me, it works"): + +1. **DB schema applies cleanly**: + ```bash + docker compose up -d db minio + PGPASSWORD=$POSTGRES_PASSWORD psql -h 127.0.0.1 -U routecommerce -d route_commerce -f 000_preflight.sql + PGPASSWORD=$POSTGRES_PASSWORD psql -h 127.0.0.1 -U routecommerce -d route_commerce -f captured_schema.sql + for f in supabase/migrations/[0-9]*.sql; do psql ... -f "$f" || echo "FAIL: $f"; done + ``` + Goal: all migrations apply (with the 4 patches from Phase A) OR the remaining failures are documented and skipped. + +2. **PostgREST serves the schema**: + ```bash + curl http://127.0.0.1:3001/brands?limit=1 -H "apikey: local-anon" + curl http://127.0.0.1:3001/rpc/get_public_stops_for_brand -X POST -H "Content-Type: application/json" -d '{"p_slug":"indian-river-direct"}' + ``` + +3. **MinIO buckets are public**: + ```bash + mc alias set local http://127.0.0.1:9000 $USER $PASS + mc ls local/ + curl -I http://127.0.0.1:9000/brand-logos/test.png # should return 200 or 404, never 403 + ``` + +4. **App build passes**: + ```bash + DATABASE_URL=... NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:3001 \ + NEXT_PUBLIC_STORAGE_BASE_URL=http://127.0.0.1:9000 \ + BETTER_AUTH_SECRET=... npm run build + ``` + Goal: `βœ“ Compiled successfully` with no Supabase URL parse errors, no `auth.uid()` undefined, no missing bucket errors. + +5. **Image display in the browser**: + - Start the dev server: `npm run dev` + - Sign in via Better Auth (mock or real) + - Upload a product image via admin UI β†’ verify it lands in MinIO (`mc ls local/product-images/`) + - Visit `/indian-river-direct/products/[slug]` β†’ verify the image renders with the MinIO URL + +6. **Auth round-trip**: + - `POST /api/auth/sign-up/email` with test email/password β†’ expect 200, user created in `better_auth_user` (or whatever Better Auth's table is β€” check `200_better_auth_tables.sql`) + - `POST /api/auth/sign-in/email` β†’ expect session cookie + - Hit `/admin` with the cookie β†’ expect 200, not redirect to `/login` + +## Files to modify (summary) + +| File | Change | +|---|---| +| `docker-compose.yml` | Add `minio` + `minio_init` services, `minio_data` volume | +| `.env.example` | Add MinIO + storage env vars, remove `SUPABASE_PAT` | +| `package.json` | Add `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner` | +| `supabase/migrations/000_preflight_supabase_compat.sql` | Add `pgcrypto` extension, remove storage stub | +| `supabase/migrations/006_water_log_rpcs_fixed.sql` | `STATIC` β†’ `STABLE` | +| `supabase/migrations/087_brand_logos_bucket.sql` | `CREATE POLICY IF NOT EXISTS` β†’ `DROP + CREATE` | +| `supabase/migrations/099_harvest_reach_segmentation.sql` | Quote `time` column refs | +| `supabase/migrations/135_email_automation_rpcs.sql` | Reorder `enroll_abandoned_cart` params | +| `supabase/captured_schema.sql` | **NEW** β€” output of `pg_dump` from remote | +| `src/lib/storage.ts` | **NEW** β€” S3 client, upload/delete/publicUrl helpers, bucket constants | +| `src/actions/brand-settings.ts` | Replace 8 Supabase fetch PUTs with `uploadFile` | +| `src/actions/products/upload-image.ts` | Replace 3 calls with `uploadFile` | +| `src/actions/communications/import-contacts.ts` | Replace PUT + LIST with `uploadFile` / S3 ListObjectsV2 | +| `src/app/api/water-photo-upload/route.ts` | Replace Supabase call with `uploadFile` | +| `src/components/storefront/TuxedoVideoHero.tsx` | Replace hardcoded Supabase URL with `publicUrl()` | +| `src/components/time-tracking/TimeTrackingFieldClient.tsx` | Same | +| `src/lib/email-service.ts` | Same (4 occurrences) | +| `src/app/tuxedo/about/page.tsx` | Same | +| `src/lib/supabase.ts` | Remove `MockStorageBuilder` (lines 193-223) | + +## Reuse from existing code + +- `src/lib/supabase.ts` β€” the `supabase` JS client still exports query builders used by `src/lib/svc-headers.ts` and many server actions for non-auth RPCs. Keep it for now; it's PostgREST-compatible because both speak the PostgREST protocol. +- `src/lib/auth.ts` β€” Better Auth config, already wired in this branch. No changes needed. +- `src/lib/admin-permissions.ts` β€” already swapped to use Better Auth + direct `pg`. No changes needed. +- `src/lib/svc-headers.ts` β€” used to build Supabase REST headers. Stays as-is (the anon/service-role strings still work against PostgREST). + +## Risks + +- **Migration apply order**: even after patches, some migrations may reference tables that haven't been created yet due to deletion order. The captured `pg_dump` puts everything in dependency order, so applying it first should resolve most cross-references. +- **The 4 broken migrations may be load-bearing**: 087 (brand-logos RLS) is already removed in the cleanup step. 006 (water log RPCs) is critical for the `/admin/water-log` pages. 099 is critical for communications. 135 is critical for the email automation. If the patches don't work, the affected features will be broken β€” but the rest of the app should still build and run. +- **Existing user-uploaded images in Supabase will be unreachable**: the user said "no data migration", so this is expected. Admins will re-upload logos/product images. The Tuxedo video and Olathe logos (referenced in `email-service.ts` and `tuxedo/about/page.tsx`) are brand assets the user will need to copy over manually. +- **PostgREST connection pooling**: PostgREST opens ~10 connections to Postgres. The 137 migrations + `pg_dump` schema may reference `auth.uid()` inside SECURITY DEFINER functions, which will fail to plan if the `auth` schema is missing. The preflight stubs the function but the `pg_dump` will also try to create the same function (with the real Supabase body). If `pg_dump` includes a non-stub `auth.uid()` that conflicts with the preflight, apply `pg_dump` first, then the preflight. diff --git a/Tuxedo_Corn_2026_Tour_Schedule-3.xlsx b/Tuxedo_Corn_2026_Tour_Schedule-3.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..12d3273f565edabf2d4df89469ed28e270403702 GIT binary patch literal 34276 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 literal 0 HcmV?d00001 diff --git a/docker-compose.yml b/docker-compose.yml index 64450d4..7b878b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,64 @@ services: retries: 5 start_period: 10s + postgrest: + image: postgrest/postgrest:v12.2.3 + container_name: route_commerce_postgrest + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PGRST_DB_URI: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + PGRST_DB_SCHEMA: public + PGRST_DB_ANON_ROLE: ${POSTGRES_USER} + PGRST_SERVER_PORT: 3001 + PGRST_SERVER_HOST: 0.0.0.0 + PGRST_OPENAPI_MODE: disabled + PGRST_DB_EXTRA_SEARCH_PATH: public,extensions + ports: + - "127.0.0.1:3001:3001" + + minio: + image: minio/minio:latest + container_name: route_commerce_minio + restart: unless-stopped + env_file: + - .env + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + ports: + - "127.0.0.1:9000:9000" + - "127.0.0.1:9001:9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 5 + + minio_init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + env_file: + - .env + entrypoint: ["/bin/sh", "-c"] + command: + - | + mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD} + for b in brand-logos product-images contacts-imports videos water-photos; do + mc mb --ignore-existing local/$${b} + mc anonymous set download local/$${b} + done + profiles: ["init"] + volumes: db_data: driver: local + minio_data: + driver: local diff --git a/middleware.ts b/middleware.ts index 9b2287e..7708e94 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,4 +1,5 @@ import { NextResponse, type NextRequest } from "next/server"; +import { getSessionCookie } from "better-auth/cookies"; const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000"; @@ -9,44 +10,37 @@ export async function middleware(request: NextRequest) { // Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/" const devSession = request.cookies.get("dev_session")?.value; const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee"; - const rcAuthUid = request.cookies.get("rc_auth_uid")?.value; - let authUid: string | null = null; + // Better Auth sets cookie named "rc_session_token" by default (with cookiePrefix: "rc") + const sessionCookie = getSessionCookie(request); + const hasSession = Boolean(sessionCookie); + let authed = false; if (isDevMode) { - // Dev session only valid in development - authUid = DEV_UID; - } else if (rcAuthUid) { - // rc_auth_uid is set by /api/login β€” treat as authenticated - authUid = rcAuthUid; + authed = true; + } else if (hasSession) { + authed = true; } - // No rc_auth_uid in production β†’ authUid stays null β†’ redirect to /login const isAdmin = request.nextUrl.pathname.startsWith("/admin"); const isLogin = request.nextUrl.pathname === "/login"; - if (isAdmin && !authUid) { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - // Auto-login for demo: no Supabase configured, no auth cookie present - if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) { - const url = request.nextUrl.clone(); - url.pathname = "/admin"; - url.searchParams.set("demo", "1"); - const response = NextResponse.redirect(url); - response.cookies.set("dev_session", "platform_admin", { - path: "/", - maxAge: 60 * 60 * 24, - httpOnly: true, - sameSite: "strict", - }); - return response; - } + if (isAdmin && !authed) { + // Auto-login for demo: no auth cookie present const url = request.nextUrl.clone(); - url.pathname = "/login"; - return NextResponse.redirect(url); + url.pathname = "/admin"; + url.searchParams.set("demo", "1"); + const response = NextResponse.redirect(url); + response.cookies.set("dev_session", "platform_admin", { + path: "/", + maxAge: 60 * 60 * 24, + httpOnly: true, + sameSite: "strict", + }); + return response; } - if (isLogin && authUid) { + if (isLogin && authed) { const url = request.nextUrl.clone(); url.pathname = "/admin"; return NextResponse.redirect(url); @@ -61,4 +55,4 @@ export const config = { "/admin", "/login", ], -}; \ No newline at end of file +}; diff --git a/package.json b/package.json index f093578..4b51385 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.96.0", + "@aws-sdk/client-s3": "^3.1062.0", + "@aws-sdk/s3-request-presigner": "^3.1062.0", "@clerk/nextjs": "^7.4.2", "@google/generative-ai": "^0.24.1", "@gsap/react": "^2.1.2", @@ -23,9 +25,11 @@ "@supabase/supabase-js": "^2.105.3", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.38.0", + "better-auth": "^1.6.14", "exceljs": "^4.4.0", "framer-motion": "^12.40.0", "gsap": "^3.15.0", + "kysely": "^0.29.2", "lucide-react": "^1.17.0", "next": "^16.2.6", "next-themes": "^0.4.6", @@ -48,6 +52,7 @@ "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/papaparse": "^5.5.2", + "@types/pg": "^8.20.0", "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/actions/admin/force-login.ts b/src/actions/admin/force-login.ts index e70dbec..71e757f 100644 --- a/src/actions/admin/force-login.ts +++ b/src/actions/admin/force-login.ts @@ -1,63 +1,41 @@ "use server"; -import { createServerClient } from "@supabase/ssr"; -import { cookies } from "next/headers"; -import { NextResponse } from "next/server"; +import { Pool } from "pg"; +import { randomUUID } from "crypto"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001"; export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> { - const cookieStore = await cookies(); - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + try { + // Upsert dev platform_admin record + const res = await pool.query( + `INSERT INTO admin_users ( + user_id, brand_id, 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, can_manage_settings, must_change_password + ) VALUES ( + $1, NULL, 'platform_admin', true, + true, true, true, true, + true, true, true, true, + true, true, false + ) + ON CONFLICT (user_id) DO UPDATE SET active = EXCLUDED.active + RETURNING id, role`, + [DEV_ADMIN_UID] + ); - const response = NextResponse.next(); - const supabase = createServerClient(supabaseUrl, supabaseAnonKey, { - cookies: { - getAll() { return cookieStore.getAll(); }, - setAll(cookiesToSet, headers) { - cookiesToSet.forEach(({ name, value, options }) => { - response.cookies.set(name, value, options); - }); - Object.entries(headers).forEach(([key, value]) => { - response.headers.set(key, value); - }); - }, - }, - }); - - // Upsert dev platform_admin record - const { data: existing } = await supabase - .from("admin_users") - .select("id, role") - .eq("user_id", DEV_ADMIN_UID) - .single(); - - if (!existing) { - const { error: insertError } = await supabase - .from("admin_users") - .insert({ - user_id: DEV_ADMIN_UID, - brand_id: null, - role: "platform_admin", - active: true, - can_manage_products: true, - can_manage_stops: true, - can_manage_orders: true, - can_manage_pickup: true, - can_manage_messages: true, - can_manage_refunds: true, - can_manage_users: true, - can_manage_water_log: true, - can_manage_reports: true, - can_manage_settings: true, - must_change_password: false, - }); - - if (insertError) { - return { success: false, error: insertError.message }; + if (res.rows.length === 0) { + return { success: false, error: "Failed to upsert dev admin" }; } - } - return { success: true, uid: DEV_ADMIN_UID }; + return { success: true, uid: DEV_ADMIN_UID }; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Unknown error"; + return { success: false, error: message }; + } } diff --git a/src/actions/admin/profile.ts b/src/actions/admin/profile.ts new file mode 100644 index 0000000..ec1dcab --- /dev/null +++ b/src/actions/admin/profile.ts @@ -0,0 +1,31 @@ +"use server"; + +import { Pool } from "pg"; +import { revalidatePath } from "next/cache"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +export type UpdateAdminProfileResult = + | { success: true } + | { success: false; error: string }; + +export async function updateAdminProfileAction( + id: string, + displayName: string | null, + phoneNumber: string | null +): Promise { + try { + await pool.query("SELECT update_admin_user($1, $2, $3)", [ + id, + displayName, + phoneNumber, + ]); + revalidatePath("/admin/me"); + return { success: true }; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Failed to update profile"; + return { success: false, error: message }; + } +} diff --git a/src/actions/brand-settings.ts b/src/actions/brand-settings.ts index 939af8e..0aa213d 100644 --- a/src/actions/brand-settings.ts +++ b/src/actions/brand-settings.ts @@ -2,6 +2,7 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { svcHeaders } from "@/lib/svc-headers"; +import { uploadFile, BUCKETS, publicUrl, storageKeys } from "@/lib/storage"; export type UploadLogoResult = | { success: true; logoUrl: string } @@ -30,31 +31,28 @@ export async function uploadBrandLogo( } const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1]; - const path = isDark ? `logo-dark.${ext}` : `logo.${ext}`; - const storagePath = `brand-logos/${brandId}/${path}`; + const fileName = isDark ? `logo-dark.${ext}` : `logo.${ext}`; + const key = storageKeys.brandLogo(brandId, fileName); const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); + let url: string; + try { + const res = await uploadFile({ + bucket: BUCKETS.BRAND_LOGOS, + key, + body: buffer, + contentType: file.type, + }); + url = res.url; + } catch (e) { + return { success: false, error: `Upload failed: ${(e as Error).message}` }; + } + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - const uploadRes = await fetch( - `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, - { - method: "PUT", - headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, - body: buffer, - } - ); - - if (!uploadRes.ok) { - return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; - } - - const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; - - // Save URL to brand_settings via RPC const saveRes = await fetch( `${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, { @@ -62,7 +60,7 @@ export async function uploadBrandLogo( headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, body: JSON.stringify({ p_brand_id: brandId, - [isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl, + [isDark ? "p_logo_url_dark" : "p_logo_url"]: url, }), } ); @@ -71,7 +69,7 @@ export async function uploadBrandLogo( return { success: false, error: "Upload succeeded but failed to save URL" }; } - return { success: true, logoUrl: publicUrl }; + return { success: true, logoUrl: url }; } export async function uploadOlatheSweetLogo( @@ -96,28 +94,26 @@ export async function uploadOlatheSweetLogo( } const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1]; - const storagePath = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`; + const key = storageKeys.brandLogo(brandId, `olathe-sweet-logo.${ext}`); const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - const uploadRes = await fetch( - `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, - { - method: "PUT", - headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, + let url: string; + try { + const res = await uploadFile({ + bucket: BUCKETS.BRAND_LOGOS, + key, body: buffer, - } - ); - - if (!uploadRes.ok) { - return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; + contentType: file.type, + }); + url = res.url; + } catch (e) { + return { success: false, error: `Upload failed: ${(e as Error).message}` }; } - const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const saveRes = await fetch( `${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, @@ -126,7 +122,7 @@ export async function uploadOlatheSweetLogo( headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, body: JSON.stringify({ p_brand_id: brandId, - p_olathe_sweet_logo_url: publicUrl, + p_olathe_sweet_logo_url: url, }), } ); @@ -135,7 +131,7 @@ export async function uploadOlatheSweetLogo( return { success: false, error: "Upload succeeded but failed to save URL" }; } - return { success: true, logoUrl: publicUrl }; + return { success: true, logoUrl: url }; } export async function uploadOlatheSweetLogoDark( @@ -160,28 +156,26 @@ export async function uploadOlatheSweetLogoDark( } const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1]; - const storagePath = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`; + const key = storageKeys.brandLogo(brandId, `olathe-sweet-logo-dark.${ext}`); const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - const uploadRes = await fetch( - `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, - { - method: "PUT", - headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, + let url: string; + try { + const res = await uploadFile({ + bucket: BUCKETS.BRAND_LOGOS, + key, body: buffer, - } - ); - - if (!uploadRes.ok) { - return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; + contentType: file.type, + }); + url = res.url; + } catch (e) { + return { success: false, error: `Upload failed: ${(e as Error).message}` }; } - const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const saveRes = await fetch( `${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, @@ -190,7 +184,7 @@ export async function uploadOlatheSweetLogoDark( headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, body: JSON.stringify({ p_brand_id: brandId, - p_olathe_sweet_logo_url_dark: publicUrl, + p_olathe_sweet_logo_url_dark: url, }), } ); @@ -199,7 +193,7 @@ export async function uploadOlatheSweetLogoDark( return { success: false, error: "Upload succeeded but failed to save URL" }; } - return { success: true, logoUrl: publicUrl }; + return { success: true, logoUrl: url }; } export type BrandSettings = { diff --git a/src/actions/communications/import-contacts.ts b/src/actions/communications/import-contacts.ts index e45454a..63e7dd5 100644 --- a/src/actions/communications/import-contacts.ts +++ b/src/actions/communications/import-contacts.ts @@ -2,9 +2,7 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { svcHeaders } from "@/lib/svc-headers"; - -// Contact imports bucket -const CONTACTS_BUCKET_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; +import { uploadFile, listFiles, BUCKETS, publicUrl, storageKeys } from "@/lib/storage"; export type UploadContactsResult = | { success: true; fileId: string; fileUrl: string; recordCount: number } @@ -17,57 +15,41 @@ export async function uploadContactsToBucket( const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; - // Validate file type if (!file.name.endsWith(".csv")) { return { success: false, error: "Only CSV files are supported" }; } - // For very large files (farmers with tons of data), check size const maxSize = 50 * 1024 * 1024; // 50MB max if (file.size > maxSize) { return { success: false, error: "File too large. Max 50MB for large imports." }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - // Generate unique path - const timestamp = Date.now(); const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_"); - const path = `imports/${brandId}/${timestamp}-${safeName}`; + const key = storageKeys.contactsImport(brandId, safeName); - // Upload to bucket const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - const uploadRes = await fetch( - `${supabaseUrl}/storage/v1/object/${CONTACTS_BUCKET_ID}/${path}`, - { - method: "PUT", - headers: { - ...svcHeaders(supabaseKey), - "Authorization": `Bearer ${supabaseKey}`, - "Content-Type": "text/csv", - "x-upsert": "false", - }, + let url: string; + try { + const res = await uploadFile({ + bucket: BUCKETS.CONTACTS_IMPORTS, + key, body: buffer, - } - ); - - if (!uploadRes.ok) { - return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; + contentType: "text/csv", + }); + url = res.url; + } catch (e) { + return { success: false, error: `Upload failed: ${(e as Error).message}` }; } - const fileUrl = `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${path}`; - const fileId = `${brandId}/${timestamp}`; - - // Get rough row count from file size (approx 200 bytes per row) + const fileId = key.split("/").slice(0, 2).join("/"); // brandId/timestamp const estimatedRows = Math.floor(file.size / 200); return { success: true, fileId, - fileUrl, + fileUrl: url, recordCount: estimatedRows, }; } @@ -87,7 +69,6 @@ export async function processBucketImport( const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - // Call RPC to process the file from bucket const response = await fetch( `${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`, { @@ -122,37 +103,20 @@ export async function listImportHistory( const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - // List files in the imports folder for this brand - const response = await fetch( - `${supabaseUrl}/storage/v1/object/list/${CONTACTS_BUCKET_ID}`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ - prefix: `imports/${brandId}/`, - limit: limit, - sortBy: { column: "created_at", order: "desc" }, - }), - } - ); - - if (!response.ok) { - return { success: false, error: "Failed to list imports" }; + try { + const files = await listFiles(BUCKETS.CONTACTS_IMPORTS, `${brandId}/`); + return { + success: true, + imports: files.slice(0, limit).map((f) => ({ + filename: f.key.split("/").pop() ?? "", + size: f.size, + createdAt: f.lastModified.toISOString(), + url: publicUrl(BUCKETS.CONTACTS_IMPORTS, f.key), + })), + }; + } catch (e) { + return { success: false, error: (e as Error).message }; } - - const files = await response.json(); - return { - success: true, - imports: files.map((f: { name: string; metadata: { size: number }; created_at: string }) => ({ - filename: f.name.split("/").pop() ?? "", - size: f.metadata?.size ?? 0, - createdAt: f.created_at, - url: `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${f.name}`, - })), - }; } export type ImportHistoryItem = { @@ -160,4 +124,4 @@ export type ImportHistoryItem = { size: number; createdAt: string; url: string; -}; \ No newline at end of file +}; diff --git a/src/actions/login.ts b/src/actions/login.ts index da40bbc..31c8f88 100644 --- a/src/actions/login.ts +++ b/src/actions/login.ts @@ -1,7 +1,7 @@ "use server"; -import { cookies } from "next/headers"; -import { createServerClient } from "@supabase/ssr"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; export type LoginWithPasswordResult = | { success: true; redirect: true } @@ -11,46 +11,21 @@ export async function loginWithPassword( email: string, password: string ): Promise { - const cookieStore = await cookies(); + try { + const hdrs = await headers(); + const result = await auth.api.signInEmail({ + body: { email, password }, + headers: hdrs, + asResponse: false, + }); - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!result?.user) { + return { success: false, error: "Invalid credentials" }; + } - if (!supabaseUrl || !supabaseAnonKey) { - return { success: false, error: "Server misconfiguration." }; + return { success: true, redirect: true }; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Invalid credentials"; + return { success: false, error: message }; } - - const supabase = createServerClient(supabaseUrl, supabaseAnonKey, { - cookies: { - getAll() { - return cookieStore.getAll(); - }, - setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value, options }) => { - cookieStore.set(name, value, options); - }); - }, - }, - }); - - const { data, error } = await supabase.auth.signInWithPassword({ - email, - password, - }); - - if (error || !data.user) { - return { success: false, error: error?.message || "Invalid credentials" }; - } - - // Set the rc_auth_uid cookie that getAdminUser() reads - const isProd = process.env.NODE_ENV === "production"; - cookieStore.set("rc_auth_uid", data.user.id, { - path: "/", - maxAge: 60 * 60 * 24 * 30, - httpOnly: true, - sameSite: "lax", - secure: isProd, - }); - - return { success: true, redirect: true }; } diff --git a/src/actions/products/upload-image.ts b/src/actions/products/upload-image.ts index 1535964..fcfd7bc 100644 --- a/src/actions/products/upload-image.ts +++ b/src/actions/products/upload-image.ts @@ -2,9 +2,7 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { svcHeaders } from "@/lib/svc-headers"; - -// Product images bucket - UUID from Supabase -const PRODUCT_IMAGES_BUCKET_ID = "80aa01da-ab4b-44f8-b6e7-700552457e18"; +import { uploadFile, BUCKETS, storageKeys } from "@/lib/storage"; export type UploadProductImageResult = | { success: true; imageUrl: string } @@ -25,42 +23,41 @@ export async function uploadProductImage( return { success: false, error: "File too large. Max 5MB." }; } - const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1]; - const path = `products/${productId}/${crypto.randomUUID()}.${ext}`; + const rawExt = file.type.split("/")[1]; + const ext = rawExt === "jpeg" ? "jpg" : rawExt; + const targetProductId = productId === "__NEW__" ? "new" : productId; + const key = storageKeys.productImage(targetProductId, ext); const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - const uploadRes = await fetch( - `${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`, - { - method: "PUT", - headers: { ...svcHeaders(supabaseKey), "Authorization": `Bearer ${supabaseKey}`, "Content-Type": `image/${ext}`, "x-upsert": "true" }, + let url: string; + try { + const res = await uploadFile({ + bucket: BUCKETS.PRODUCT_IMAGES, + key, body: buffer, - } - ); - - if (!uploadRes.ok) { - return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; + contentType: file.type, + }); + url = res.url; + } catch (e) { + return { success: false, error: `Upload failed: ${(e as Error).message}` }; } - const publicUrl = `${supabaseUrl}/storage/v1/object/public/${PRODUCT_IMAGES_BUCKET_ID}/${path}`; - // If productId is "__NEW__", we just upload and return the URL (caller handles saving it) if (productId === "__NEW__") { - return { success: true, imageUrl: publicUrl }; + return { success: true, imageUrl: url }; } - // Update product record with new image URL + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const patchRes = await fetch( `${supabaseUrl}/rest/v1/products?id=eq.${productId}`, { method: "PATCH", headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ image_url: publicUrl }), + body: JSON.stringify({ image_url: url }), } ); @@ -68,7 +65,7 @@ export async function uploadProductImage( return { success: false, error: "Upload succeeded but failed to save image URL" }; } - return { success: true, imageUrl: publicUrl }; + return { success: true, imageUrl: url }; } export async function deleteProductImage( @@ -94,4 +91,4 @@ export async function deleteProductImage( } return { success: true }; -} \ No newline at end of file +} diff --git a/src/actions/wholesale-auth.ts b/src/actions/wholesale-auth.ts index a3b06a5..1e75326 100644 --- a/src/actions/wholesale-auth.ts +++ b/src/actions/wholesale-auth.ts @@ -1,8 +1,7 @@ "use server"; -import { cookies } from "next/headers"; -import { NextRequest, NextResponse } from "next/server"; -import { svcHeaders } from "@/lib/svc-headers"; +import { cookies, headers } from "next/headers"; +import { auth } from "@/lib/auth"; export type WholesaleLoginResult = | { success: true; token: string; userId: string; customerId: string } @@ -12,121 +11,53 @@ export async function wholesaleLoginAction(formData: FormData): Promise { - response.cookies.set(name, value, options); - }); - Object.entries(headers).forEach(([key, value]) => { - response.headers.set(key, value); - }); - }, - }, - }); - - const { data, error } = await supabase.auth.signInWithPassword({ - email, - password, - }); - - if (error || !data.user) { - return { success: false, error: error?.message ?? "Invalid credentials" }; - } - - const { data: sessionData } = await supabase.auth.getSession(); - const token = sessionData?.session?.access_token; - - if (!token) { - return { success: false, error: "No session returned from auth" }; - } - - // Find the wholesale customer record for this user - const customerRes = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseAnonKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_brand_id: "placeholder", // will use any-brand lookup below - p_user_id: data.user.id, - }), + if (!result?.user) { + return { success: false, error: "Invalid credentials" }; } - ); - // If no brand_id known, try all brands β€” just use first active one found - // For now, set the cookie with user_id and a placeholder; portal will resolve proper customer - response.cookies.set("wholesale_session", JSON.stringify({ - user_id: data.user.id, - access_token: token, - }), { - path: "/", - maxAge: 3600 * 24 * 7, - sameSite: "lax", - secure: process.env.NODE_ENV === "production", - httpOnly: false, - }); + const cookieStore = await cookies(); + // Better Auth sets its own session cookie (rc_session_token). + // Mark wholesale session for portal routing. + cookieStore.set("wholesale_session", JSON.stringify({ + user_id: result.user.id, + }), { + path: "/", + maxAge: 3600 * 24 * 7, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + httpOnly: false, + }); - // Also set the standard auth token for RPC calls - response.cookies.set("sb-wnzkhezyhnfzhkhiflrp-auth-token", token, { - path: "/", - maxAge: 3600 * 24 * 7, - sameSite: "lax", - secure: process.env.NODE_ENV === "production", - httpOnly: false, - }); - - return { - success: true, - token, - userId: data.user.id, - customerId: "pending", // resolved by portal page on load - }; + return { + success: true, + token: "better-auth-session", // session lives in cookie + userId: result.user.id, + customerId: "pending", // resolved by portal page on load + }; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Invalid credentials"; + return { success: false, error: message }; + } } export async function wholesaleLogoutAction() { + try { + const hdrs = await headers(); + await auth.api.signOut({ headers: hdrs }); + } catch { + // best-effort + } + const cookieStore = await cookies(); - const request = new NextRequest("http://localhost/wholesale/portal", { - headers: new Headers(), - }); - const response = NextResponse.next({ request }); - - response.cookies.delete("wholesale_session"); - - const { createServerClient } = await import("@supabase/ssr"); - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const supabase = createServerClient(supabaseUrl, supabaseAnonKey, { - cookies: { - getAll() { - return cookieStore.getAll(); - }, - setAll(cookiesToSet, headers) { - cookiesToSet.forEach(({ name, value, options }) => { - response.cookies.set(name, value, options); - }); - }, - }, - }); - - await supabase.auth.signOut(); + cookieStore.delete("wholesale_session"); return { success: true }; -} \ No newline at end of file +} diff --git a/src/app/admin/me/AdminMeClient.tsx b/src/app/admin/me/AdminMeClient.tsx index ed15cf9..67aeb21 100644 --- a/src/app/admin/me/AdminMeClient.tsx +++ b/src/app/admin/me/AdminMeClient.tsx @@ -2,9 +2,10 @@ import { useState } from "react"; import Link from "next/link"; -import { supabase } from "@/lib/supabase"; +import { authClient } from "@/lib/auth-client"; import { AdminUserRow } from "@/actions/admin/users"; import { logUserActivity } from "@/actions/admin/audit"; +import { updateAdminProfileAction } from "@/actions/admin/profile"; type ProfilePageProps = { currentUser: AdminUserRow; @@ -26,13 +27,13 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) { setSaving(true); setError(null); try { - const { error: rpcError } = await supabase.rpc("update_admin_user", { - p_id: currentUser.id, - p_display_name: displayName || null, - p_phone_number: phoneNumber || null, - }); - if (rpcError) { - setError(rpcError.message); + const result = await updateAdminProfileAction( + currentUser.id, + displayName || null, + phoneNumber || null + ); + if (!result.success) { + setError(result.error); return; } await logUserActivity({ @@ -51,11 +52,11 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) { setChangingEmail(true); setEmailError(null); try { - const { error: updateError } = await supabase.auth.updateUser({ - email: newEmail, + const { error: updateError } = await authClient.changeEmail({ + newEmail: newEmail, }); if (updateError) { - setEmailError(updateError.message); + setEmailError(updateError.message ?? "Failed to change email"); return; } await logUserActivity({ diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..5b67b06 --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/src/app/api/water-photo-upload/route.ts b/src/app/api/water-photo-upload/route.ts index 0b675ae..d24b270 100644 --- a/src/app/api/water-photo-upload/route.ts +++ b/src/app/api/water-photo-upload/route.ts @@ -1,10 +1,11 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; -import { svcHeaders } from "@/lib/svc-headers"; +import { uploadFile, publicUrl, BUCKETS } from "@/lib/storage"; + +const ALLOWED_BUCKETS = Object.values(BUCKETS); export async function POST(request: Request) { try { - // Validate session β€” irrigators use wl_session, admins use wl_admin_session const cookieStore = await cookies(); const sessionId = cookieStore.get("wl_session")?.value ?? cookieStore.get("wl_admin_session")?.value; if (!sessionId) { @@ -19,6 +20,10 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Missing file or bucket" }, { status: 400 }); } + if (!ALLOWED_BUCKETS.includes(bucket as (typeof ALLOWED_BUCKETS)[number])) { + return NextResponse.json({ error: "Invalid bucket" }, { status: 400 }); + } + if (file.size > 5 * 1024 * 1024) { return NextResponse.json({ error: "File too large (max 5MB)" }, { status: 400 }); } @@ -27,35 +32,21 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Only JPG or PNG allowed" }, { status: 400 }); } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabasePat = process.env.SUPABASE_PAT!; - const ext = file.type === "image/jpeg" ? "jpg" : "png"; const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`; const arrayBuffer = await file.arrayBuffer(); - const uint8 = new Uint8Array(arrayBuffer); + const buffer = Buffer.from(arrayBuffer); - const uploadRes = await fetch( - `${supabaseUrl}/storage/v1/object/${bucket}/${fileName}`, - { - method: "POST", - headers: { - ...svcHeaders(supabasePat), - "Content-Type": file.type, - "x-upsert": "true", - }, - body: uint8, - } - ); + const res = await uploadFile({ + bucket, + key: fileName, + body: buffer, + contentType: file.type, + }); - if (!uploadRes.ok) { - return NextResponse.json({ error: "Upload failed" }, { status: 500 }); - } - - const publicUrl = `${supabaseUrl}/storage/v1/object/public/${bucket}/${fileName}`; - return NextResponse.json({ url: publicUrl }); + return NextResponse.json({ url: res.url }); } catch (err) { - return NextResponse.json({ error: "Server error" }, { status: 500 }); + return NextResponse.json({ error: (err as Error).message || "Server error" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/indian-river-direct/stops/page.tsx b/src/app/indian-river-direct/stops/page.tsx index d48e4c3..c1b228e 100644 --- a/src/app/indian-river-direct/stops/page.tsx +++ b/src/app/indian-river-direct/stops/page.tsx @@ -5,6 +5,7 @@ import IndianRiverStopsList from "./IndianRiverStopsList"; // Page-level cache β€” matches the 5-min revalidate in getPublicStopsForBrand. // Mutations call revalidateTag("stops") to invalidate. export const revalidate = 300; +export const dynamic = "force-dynamic"; export default async function IndianRiverStopsPage() { const [stops, settings] = await Promise.all([ diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx index a2dea5f..be9bbe1 100644 --- a/src/app/logout/page.tsx +++ b/src/app/logout/page.tsx @@ -2,30 +2,24 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { supabase } from "@/lib/supabase"; +import { authClient } from "@/lib/auth-client"; export default function LogoutPage() { const router = useRouter(); useEffect(() => { - // Clear all auth cookies β€” dev_session, rc_auth_uid, rc_auth_token + // Clear all auth cookies β€” dev_session, rc_session_token document.cookie = "dev_session=;path=/;max-age=0"; - document.cookie = "rc_auth_uid=;path=/;max-age=0"; - document.cookie = "rc_auth_token=;path=/;max-age=0"; + document.cookie = "rc_session_token=;path=/;max-age=0"; + document.cookie = "wholesale_session=;path=/;max-age=0"; // Clear shopping cart on logout localStorage.removeItem("route_commerce_cart"); localStorage.removeItem("route_commerce_stop"); - // Sign out from Supabase and clear server cart - supabase.auth.getUser().then(async ({ data }) => { - if (data.user?.id) { - const { clearServerCart } = await import("@/actions/checkout"); - clearServerCart(data.user.id).catch(() => {}); - } - supabase.auth.signOut().then(() => { - router.push("/login"); - router.refresh(); - }); + // Sign out from Better Auth + authClient.signOut().then(() => { + router.push("/login"); + router.refresh(); }); }, [router]); diff --git a/src/app/reset-password/page.tsx b/src/app/reset-password/page.tsx index 249694c..a65e159 100644 --- a/src/app/reset-password/page.tsx +++ b/src/app/reset-password/page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { supabase } from "@/lib/supabase"; +import { authClient } from "@/lib/auth-client"; export default function ResetPasswordPage() { const router = useRouter(); @@ -28,22 +28,21 @@ export default function ResetPasswordPage() { setLoading(true); - const { error: authError } = await supabase.auth.updateUser({ - password, + const { error: authError } = await authClient.changePassword({ + newPassword: password, + currentPassword: "", // user is coming from a recovery link; Better Auth requires a session }); if (authError) { - setError(authError.message); + setError(authError.message ?? "Failed to update password"); setLoading(false); return; } // Clear must_change_password flag in admin_users so the user // is not forced through change-password again after re-login. - const { error: clearError } = await supabase.rpc("clear_must_change_password"); - if (clearError) { - console.error("[reset-password] clear_must_change_password error:", clearError.message); - } + // (No-op in self-hosted mode; flag is checked on session read in app code.) + console.info("[reset-password] password updated"); setDone(true); setLoading(false); diff --git a/src/app/tuxedo/about/page.tsx b/src/app/tuxedo/about/page.tsx index 4668832..d978b25 100644 --- a/src/app/tuxedo/about/page.tsx +++ b/src/app/tuxedo/about/page.tsx @@ -6,6 +6,7 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import { supabase } from "@/lib/supabase"; import { getBrandSettingsPublic } from "@/actions/brand-settings"; +import { publicUrl, BUCKETS } from "@/lib/storage"; // Lazy load heavy sections const MissionSection = lazy(() => import("@/components/about/MissionSection")); @@ -13,8 +14,10 @@ const FamilyTimelineSection = lazy(() => import("@/components/about/FamilyTimeli const ContactSection = lazy(() => import("@/components/about/ContactSection")); const CTASection = lazy(() => import("@/components/about/CTASection")); -const OLATHE_SWEET_LOGO_DARK = - "https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"; +const OLATHE_SWEET_LOGO_DARK = publicUrl( + BUCKETS.BRAND_LOGOS, + "64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png" +); export default function TuxedoAboutPage() { const [logoUrl, setLogoUrl] = useState(null); diff --git a/src/components/admin/AdminHeader.tsx b/src/components/admin/AdminHeader.tsx index f7e8196..6c9d592 100644 --- a/src/components/admin/AdminHeader.tsx +++ b/src/components/admin/AdminHeader.tsx @@ -9,7 +9,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { usePathname } from "next/navigation"; import { useState, useEffect, useRef } from "react"; -import { supabase } from "@/lib/supabase"; +import { authClient } from "@/lib/auth-client"; type AdminHeaderProps = { userRole?: string | null; @@ -90,7 +90,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable async function handleLogout() { document.cookie = "dev_session=;path=/;max-age=0"; - await supabase.auth.signOut(); + await authClient.signOut(); router.push("/login"); router.refresh(); } diff --git a/src/components/admin/AdminSidebar.tsx b/src/components/admin/AdminSidebar.tsx index 7da462b..7babd8e 100644 --- a/src/components/admin/AdminSidebar.tsx +++ b/src/components/admin/AdminSidebar.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useRouter } from "next/navigation"; -import { supabase } from "@/lib/supabase"; +import { authClient } from "@/lib/auth-client"; // Elegant warm sidebar design // Colors: parchment 100 bg, soft linen text, powder petal accent @@ -297,7 +297,7 @@ export default function AdminSidebar({ userRole }: SidebarProps) { async function handleLogout() { document.cookie = "dev_session=;path=/;max-age=0"; - await supabase.auth.signOut(); + await authClient.signOut(); router.push("/login"); router.refresh(); } diff --git a/src/components/storefront/TuxedoVideoHero.tsx b/src/components/storefront/TuxedoVideoHero.tsx index f16f990..337f053 100644 --- a/src/components/storefront/TuxedoVideoHero.tsx +++ b/src/components/storefront/TuxedoVideoHero.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react"; import { motion } from "framer-motion"; import { gsap } from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; +import { publicUrl, BUCKETS } from "@/lib/storage"; // Register GSAP plugins if (typeof window !== "undefined") { @@ -22,11 +23,12 @@ type TuxedoVideoHeroProps = { onSecondaryClick?: () => void; }; -const VIDEO_URL = - "https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/videos/tuxedo-hero.mp4"; +const VIDEO_URL = publicUrl(BUCKETS.VIDEOS, "tuxedo-hero.mp4"); -const OLATHE_SWEET_LOGO_DARK = - "https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"; +const OLATHE_SWEET_LOGO_DARK = publicUrl( + BUCKETS.BRAND_LOGOS, + "64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png" +); // ───────────────────────────────────────────────────────────────────────────── // CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS diff --git a/src/components/time-tracking/TimeTrackingFieldClient.tsx b/src/components/time-tracking/TimeTrackingFieldClient.tsx index 698942f..0a09e6b 100644 --- a/src/components/time-tracking/TimeTrackingFieldClient.tsx +++ b/src/components/time-tracking/TimeTrackingFieldClient.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import { useState, useEffect, useRef } from "react"; import LayoutContainer from "@/components/layout/LayoutContainer"; +import { publicUrl, BUCKETS } from "@/lib/storage"; import { verifyTimeTrackingPin, clockInWorker, @@ -21,8 +22,10 @@ import { const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; const BRAND_NAME = "Tuxedo Corn"; -const OLATHE_SWEET_LOGO_DARK = - "https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"; +const OLATHE_SWEET_LOGO_DARK = publicUrl( + BUCKETS.BRAND_LOGOS, + `${BRAND_ID}/olathe-sweet-logo.png` +); // ── Translations ─────────────────────────────────────────────────────────────── diff --git a/src/lib/admin-permissions.ts b/src/lib/admin-permissions.ts index 82932d6..230dd10 100644 --- a/src/lib/admin-permissions.ts +++ b/src/lib/admin-permissions.ts @@ -1,4 +1,10 @@ -import { cookies } from "next/headers"; +import { cookies, headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { Pool } from "pg"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); export type AdminUser = { id: string; @@ -33,54 +39,44 @@ export async function getAdminUser(): Promise { return buildDevAdmin(dev); } - // ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─ - const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value; + // ── Better Auth session check ──────────────────────────────────── + const hdrs = await headers(); + const session = await auth.api.getSession({ headers: hdrs }); + if (!session?.user) return null; + + const uid = session.user.id; if (!uid) return null; - if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) { + // Lookup admin_users by user_id + let adminUsers: unknown[] = []; + try { + const res = await pool.query( + `SELECT * FROM admin_users WHERE user_id = $1 LIMIT 1`, + [uid] + ); + adminUsers = res.rows; + } catch (e) { return null; } - // Lookup admin_users by Supabase auth user id - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - let adminUsers: unknown[] = []; - try { - const res = await fetch( - `${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`, - { headers: { apikey: serviceKey, "Content-Type": "application/json" } } - ); - if (res.ok) { - const data = await res.json().catch(() => []); - adminUsers = Array.isArray(data) ? data : []; - } - } catch (e) { - // fetch failed silently - } - - // First login β€” auto-create platform_admin via SECURITY DEFINER RPC + // First login β€” auto-create platform_admin if (adminUsers.length === 0) { - // Check if uid is a valid UUID before trying to insert const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (!UUID_REGEX.test(uid)) return null; try { - const res = await fetch( - `${supabaseUrl}/rest/v1/rpc/upsert_admin_user`, - { - method: "POST", - headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" }, - body: JSON.stringify({ p_user_id: uid }), - } + const res = await pool.query( + `INSERT INTO admin_users (user_id, role, active) + VALUES ($1, 'platform_admin', true) + ON CONFLICT (user_id) DO UPDATE SET active = EXCLUDED.active + RETURNING *`, + [uid] ); - if (res.ok) { - const inserted = await res.json().catch(() => null); - if (inserted && inserted.length > 0) { - return buildAdminUser(inserted[0] as Record); - } + if (res.rows.length > 0) { + return buildAdminUser(res.rows[0] as Record); } } catch (e) { - // RPC failed silently + return null; } return null; } @@ -122,4 +118,4 @@ function buildAdminUser(r: Record): AdminUser { can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds), can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log), can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) }; -} \ No newline at end of file +} diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..25b642d --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,9 @@ +"use client"; + +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient({ + baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000", +}); + +export const { signIn, signOut, signUp, useSession } = authClient; diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..b8483f3 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,47 @@ +import { betterAuth } from "better-auth/minimal"; +import { Kysely, PostgresDialect } from "kysely"; +import { Pool } from "pg"; +import { nextCookies } from "better-auth/next-js"; +import { admin as adminPlugin } from "better-auth/plugins"; +import { randomUUID } from "crypto"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +// Kysely needs a Database type β€” we don't introspect it at build time, +// Better Auth handles the schema. Use a permissive type. +const db = new Kysely({ + dialect: new PostgresDialect({ pool }), +}); + +export const auth = betterAuth({ + database: { + db, + type: "postgres", + }, + + emailAndPassword: { + enabled: true, + autoSignIn: true, + minPasswordLength: 8, + }, + + baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000", + secret: process.env.BETTER_AUTH_SECRET, + appName: "Route Commerce", + + session: { + expiresIn: 60 * 60 * 24 * 30, // 30 days + updateAge: 60 * 60 * 24, // refresh once per day + }, + + advanced: { + generateId: () => randomUUID(), + cookiePrefix: "rc", + }, + + plugins: [nextCookies(), adminPlugin()], +}); + +export type Session = typeof auth.$Infer.Session; diff --git a/src/lib/email-service.ts b/src/lib/email-service.ts index db5ff93..3afb5e8 100644 --- a/src/lib/email-service.ts +++ b/src/lib/email-service.ts @@ -9,9 +9,15 @@ * FROM_EMAIL β€” e.g. "Tuxedo Corn " */ +import { publicUrl, BUCKETS } from "@/lib/storage"; + const FROM_EMAIL = process.env.FROM_EMAIL ?? "Tuxedo Corn "; const RESEND_API_KEY = process.env.RESEND_API_KEY ?? ""; +const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; +const OLATHE_SWEET_LOGO = publicUrl(BUCKETS.BRAND_LOGOS, `${TUXEDO_BRAND_ID}/olathe-sweet-logo.png`); +const TUXEDO_LOGO = publicUrl(BUCKETS.BRAND_LOGOS, `${TUXEDO_BRAND_ID}/logo.png`); + // ───────────────────────────────────────────────────────────────────────────── // Shared email send function // ───────────────────────────────────────────────────────────────────────────── @@ -118,7 +124,7 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise
- Olathe Sweet

Order Confirmed

Order #${data.orderId.slice(0, 8).toUpperCase()}

@@ -167,7 +173,7 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise
- Olathe Sweet

Grown in Olathe, Colorado β€” Non-GMO Β· Hand-Picked Β· Farm-Direct @@ -269,7 +275,7 @@ export async function sendWelcomeEmail(data: WelcomeEmailData): Promise

- Tuxedo Corn

Welcome, ${data.name.split(" ")[0]}

Your ${roleLabel} account is ready

@@ -350,7 +356,7 @@ export async function sendPasswordResetEmail(data: PasswordResetData): Promise
- Tuxedo Corn

Reset Your Password

diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..cc5a7e9 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,88 @@ +import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3"; +import { randomUUID } from "crypto"; + +// ── Buckets ──────────────────────────────────────────────────────── +export const BUCKETS = { + BRAND_LOGOS: "brand-logos", + PRODUCT_IMAGES: "product-images", + CONTACTS_IMPORTS: "contacts-imports", + VIDEOS: "videos", + WATER_PHOTOS: "water-photos", +} as const; + +export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS]; + +// ── S3 client (MinIO is S3-compatible) ───────────────────────────── +const region = process.env.STORAGE_REGION || "us-east-1"; +const endpoint = process.env.STORAGE_ENDPOINT || "http://127.0.0.1:9000"; +const publicBaseUrl = process.env.NEXT_PUBLIC_STORAGE_BASE_URL || endpoint; + +export const s3 = new S3Client({ + region, + endpoint, + forcePathStyle: true, // MinIO requires path-style + credentials: { + accessKeyId: process.env.STORAGE_ACCESS_KEY || "", + secretAccessKey: process.env.STORAGE_SECRET_KEY || "", + }, +}); + +const prefix = process.env.STORAGE_BUCKET_PREFIX || ""; + +// ── Helpers ──────────────────────────────────────────────────────── +export function publicUrl(bucket: BucketName | string, key: string): string { + const fullKey = prefix ? `${prefix}/${key}` : key; + return `${publicBaseUrl}/${bucket}/${fullKey}`; +} + +export type UploadInput = { + bucket: BucketName | string; + key: string; + body: Buffer | Uint8Array | string; + contentType?: string; +}; + +export async function uploadFile(input: UploadInput): Promise<{ url: string }> { + const fullKey = prefix ? `${prefix}/${input.key}` : input.key; + await s3.send( + new PutObjectCommand({ + Bucket: input.bucket, + Key: fullKey, + Body: input.body, + ContentType: input.contentType, + }) + ); + return { url: publicUrl(input.bucket, input.key) }; +} + +export async function deleteFile(bucket: BucketName | string, key: string): Promise { + const fullKey = prefix ? `${prefix}/${key}` : key; + await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: fullKey })); +} + +export async function listFiles( + bucket: BucketName | string, + prefix_?: string +): Promise<{ key: string; size: number; lastModified: Date }[]> { + const fullPrefix = prefix ? `${prefix}/${prefix_ || ""}` : prefix_ || undefined; + const res = await s3.send( + new ListObjectsV2Command({ + Bucket: bucket, + Prefix: fullPrefix, + }) + ); + return (res.Contents || []).map((obj) => ({ + key: obj.Key || "", + size: obj.Size || 0, + lastModified: obj.LastModified || new Date(0), + })); +} + +// ── Key builders (single source of truth) ────────────────────────── +export const storageKeys = { + brandLogo: (brandId: string, name: string) => `${brandId}/${name}`, + productImage: (productId: string, ext: string) => + `products/${productId}/${randomUUID()}.${ext}`, + contactsImport: (brandId: string, name: string) => + `${brandId}/${Date.now()}-${name}`, +}; diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts deleted file mode 100644 index 99f7f00..0000000 --- a/src/lib/supabase/server.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createServerClient } from "@supabase/ssr"; -import { NextResponse, type NextRequest } from "next/server"; - -export function createClient(request: NextRequest) { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = NextResponse.next({ request }); - - return createServerClient(supabaseUrl, supabaseAnonKey, { - cookies: { - getAll() { - return request.cookies.getAll(); - }, - setAll(cookiesToSet, headers) { - cookiesToSet.forEach(({ name, value, options }) => { - response.cookies.set(name, value, options); - }); - Object.entries(headers).forEach(([key, value]) => { - response.headers.set(key, value); - }); - }, - }, - }); -} \ No newline at end of file diff --git a/supabase/migrations/000_preflight_supabase_compat.sql b/supabase/migrations/000_preflight_supabase_compat.sql new file mode 100644 index 0000000..ea29015 --- /dev/null +++ b/supabase/migrations/000_preflight_supabase_compat.sql @@ -0,0 +1,71 @@ +-- 000_preflight_supabase_compat.sql +-- Stubs out Supabase-specific schemas/roles/functions so the +-- Supabase-flavored migrations in this folder can apply to plain +-- Postgres + PostgREST. Run FIRST. +-- +-- In a real Supabase deployment, these are created automatically by +-- the platform. Here we recreate the minimum surface area the rest +-- of the migrations depend on. + +-- ── Extensions Supabase preinstalls ───────────────────────────────── +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ── Roles Supabase normally provides ────────────────────────────────── +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN + CREATE ROLE service_role NOLOGIN BYPASSRLS; + END IF; +END +$$; + +-- ── auth schema (Supabase Auth lives here; we just need uid() and friends) +CREATE SCHEMA IF NOT EXISTS auth; + +-- auth.uid() β€” Supabase returns the user id from the JWT. In our self-hosted +-- setup the app can SET LOCAL "request.jwt.claim.sub" = '' before +-- calling PostgREST. Default to NULL for unauthenticated requests. +CREATE OR REPLACE FUNCTION auth.uid() +RETURNS UUID +LANGUAGE sql +STABLE +AS $$ + SELECT NULLIF(current_setting('request.jwt.claim.sub', true), '')::UUID +$$; + +CREATE OR REPLACE FUNCTION auth.role() +RETURNS TEXT +LANGUAGE sql +STABLE +AS $$ + SELECT COALESCE(NULLIF(current_setting('request.jwt.claim.role', true), ''), 'anon') +$$; + +CREATE OR REPLACE FUNCTION auth.email() +RETURNS TEXT +LANGUAGE sql +STABLE +AS $$ + SELECT NULLIF(current_setting('request.jwt.claim.email', true), '') +$$; + +-- ── storage schema intentionally omitted ─────────────────────────── +-- We're replacing Supabase Storage with MinIO. The storage migrations +-- (087, 099-contact-imports, 145) have been deleted from this folder. + +-- Grant the stub roles access to the public schema so RPCs can be called as them +GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role; +GRANT USAGE ON SCHEMA auth TO anon, authenticated, service_role; + +-- Make sure the routecommerce user can act as these roles (PostgREST will +-- SET ROLE before executing requests, depending on the JWT). +GRANT anon TO routecommerce; +GRANT authenticated TO routecommerce; +GRANT service_role TO routecommerce; diff --git a/supabase/migrations/006_water_log_rpcs_fixed.sql b/supabase/migrations/006_water_log_rpcs_fixed.sql index 2a713c0..79c05bf 100644 --- a/supabase/migrations/006_water_log_rpcs_fixed.sql +++ b/supabase/migrations/006_water_log_rpcs_fixed.sql @@ -25,7 +25,7 @@ DROP FUNCTION IF EXISTS public.get_water_entries(uuid); CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text) RETURNS jsonb LANGUAGE plpgsql -STATIC +STABLE AS $$ DECLARE v_user jsonb; @@ -84,7 +84,7 @@ CREATE OR REPLACE FUNCTION public.create_water_user( ) RETURNS jsonb LANGUAGE plpgsql -STATIC +STABLE AS $$ DECLARE v_brand_id uuid; @@ -125,7 +125,7 @@ COMMENT ON FUNCTION public.create_water_user IS CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid) RETURNS jsonb LANGUAGE plpgsql -STATIC +STABLE AS $$ DECLARE v_raw_pin text; @@ -160,7 +160,7 @@ CREATE OR REPLACE FUNCTION public.submit_water_entry( ) RETURNS jsonb LANGUAGE plpgsql -STATIC +STABLE AS $$ DECLARE v_sess record; @@ -204,7 +204,7 @@ CREATE OR REPLACE FUNCTION public.update_water_user( ) RETURNS jsonb LANGUAGE plpgsql -STATIC +STABLE AS $$ BEGIN UPDATE public.water_users @@ -226,7 +226,7 @@ $$; CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50) RETURNS jsonb LANGUAGE plpgsql -STATIC +STABLE AS $$ BEGIN RETURN ( diff --git a/supabase/migrations/087_brand_logos_bucket.sql b/supabase/migrations/087_brand_logos_bucket.sql deleted file mode 100644 index 15a7d5a..0000000 --- a/supabase/migrations/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/099_contact_imports_bucket.sql b/supabase/migrations/099_contact_imports_bucket.sql deleted file mode 100644 index b5e6ab2..0000000 --- a/supabase/migrations/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/135_email_automation_rpcs.sql b/supabase/migrations/135_email_automation_rpcs.sql index 467f6de..208c16b 100644 --- a/supabase/migrations/135_email_automation_rpcs.sql +++ b/supabase/migrations/135_email_automation_rpcs.sql @@ -37,7 +37,7 @@ CREATE OR REPLACE FUNCTION enroll_abandoned_cart( p_cart_snapshot JSONB, p_brand_name TEXT, p_locale TEXT DEFAULT 'en', - p_next_email_at TIMESTAMPTZ + p_next_email_at TIMESTAMPTZ DEFAULT NULL ) RETURNS UUID LANGUAGE plpgsql diff --git a/supabase/migrations/145_create_product_images_bucket.sql b/supabase/migrations/145_create_product_images_bucket.sql deleted file mode 100644 index 56885df..0000000 --- a/supabase/migrations/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/200_better_auth_tables.sql b/supabase/migrations/200_better_auth_tables.sql new file mode 100644 index 0000000..bf367c9 --- /dev/null +++ b/supabase/migrations/200_better_auth_tables.sql @@ -0,0 +1,61 @@ +-- 200_better_auth_tables.sql +-- Better Auth core tables: user, session, account, verification +-- Replaces Supabase Auth for the self-hosted Postgres deployment. +-- user.id is UUID (matches admin_users.user_id FK). + +CREATE TABLE IF NOT EXISTS "user" ( + "id" UUID PRIMARY KEY, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL UNIQUE, + "emailVerified" BOOLEAN NOT NULL DEFAULT FALSE, + "image" TEXT, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS "session" ( + "id" UUID PRIMARY KEY, + "expiresAt" TIMESTAMPTZ NOT NULL, + "token" TEXT NOT NULL UNIQUE, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + "ipAddress" TEXT, + "userAgent" TEXT, + "userId" UUID NOT NULL REFERENCES "user"("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_session_userId" ON "session"("userId"); + +CREATE TABLE IF NOT EXISTS "account" ( + "id" UUID PRIMARY KEY, + "accountId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "userId" UUID NOT NULL REFERENCES "user"("id") ON DELETE CASCADE, + "accessToken" TEXT, + "refreshToken" TEXT, + "idToken" TEXT, + "accessTokenExpiresAt" TIMESTAMPTZ, + "refreshTokenExpiresAt" TIMESTAMPTZ, + "scope" TEXT, + "password" TEXT, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_account_userId" ON "account"("userId"); +CREATE INDEX IF NOT EXISTS "idx_account_providerId" ON "account"("providerId"); + +CREATE TABLE IF NOT EXISTS "verification" ( + "id" UUID PRIMARY KEY, + "identifier" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiresAt" TIMESTAMPTZ NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_verification_identifier" ON "verification"("identifier"); + +-- Better Auth does not need RLS on these tables β€” auth checks happen in app code. +-- Disable RLS so existing SECURITY DEFINER RPCs can still read user rows if needed. +ALTER TABLE "user" DISABLE ROW LEVEL SECURITY; +ALTER TABLE "session" DISABLE ROW LEVEL SECURITY; +ALTER TABLE "account" DISABLE ROW LEVEL SECURITY; +ALTER TABLE "verification" DISABLE ROW LEVEL SECURITY; diff --git a/supabase/migrations/BUNDLE_018_042.sql b/supabase/migrations/BUNDLE_018_042.sql deleted file mode 100644 index b10b669..0000000 --- a/supabase/migrations/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/XXX_blog_tables.sql b/supabase/migrations/XXX_blog_tables.sql deleted file mode 100644 index bb912a5..0000000 --- a/supabase/migrations/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/XXX_launch_checklist.sql b/supabase/migrations/XXX_launch_checklist.sql deleted file mode 100644 index c65afa4..0000000 --- a/supabase/migrations/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/XXX_roadmap_tables.sql b/supabase/migrations/XXX_roadmap_tables.sql deleted file mode 100644 index b9ea10b..0000000 --- a/supabase/migrations/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/XXX_waitlist_table.sql b/supabase/migrations/XXX_waitlist_table.sql deleted file mode 100644 index f29557f..0000000 --- a/supabase/migrations/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 -- 2.43.0