13 Commits

Author SHA1 Message Date
tyler d892b3f64f feat(selfhost): complete Supabase removal migration
Deploy to route.crispygoat.com / deploy (push) Failing after 9s
Phase 1 — Pattern library
- Add src/lib/api.ts (typed PostgREST client, anon-key)
- Add src/lib/svc-fetch.ts (service-role fetch + PostgREST client)
- Add src/lib/db-types.ts (Database generic, RowOf helper)
- Add src/lib/auth-admin.ts (better-auth admin wrappers: createUser, listUsers, removeUser, requestPasswordReset, validateSession)

Phase 2 — Server action migration
- Migrate all 67 server actions off @supabase/supabase-js to svcApi/svcRpc
- Replace service.auth.admin.* with authAdmin.* (better-auth)
- Replace publicApi.auth.* with authAdmin.* (better-auth)
- Add typed single() null guards + nullable column fallbacks

Phase 3 — Delete mock data + debug routes
- Remove if(useMockData) branches from 7 files (-226 lines)
- Delete src/lib/mock-data.ts (no longer referenced)
- Delete 7 debug API routes (debug-admin-users, debug-auth, debug-cookie, debug-env, debug-get-admin-users, debug-hello, debug-me)

Phase 4 — Hard cut
- Delete src/lib/supabase.ts (orphan — no importers)
- Delete src/app/api/supabase + src/app/api/supabase-test routes
- Remove @supabase/ssr + @supabase/supabase-js from package.json
- Rename env vars: NEXT_PUBLIC_SUPABASE_URL → NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY → NEXT_PUBLIC_API_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY → POSTGREST_SERVICE_KEY

Phase 5 — Verify
- npx tsc --noEmit: 0 errors
- npm run lint: 0 new errors from migration (104 pre-existing errors unrelated)
- npm run build: same failure mode as main worktree (PostgREST-dependent prerender), no regression

Net: 158 files changed, +1640 / -1903 lines (-263 net)
2026-06-05 20:17:02 +00:00
tyler e7ac495831 docs: explicit step to clean up test brands after restore
The captured production data still includes 3 test brands (Sunrise,
Green Valley, Orchard) created during the migration work. Promote the
note in 'Notes' to a fully reproducible post-restore step with the
exact DELETE statement and verification query.
2026-06-05 17:54:41 +00:00
tyler c9b1c49f53 selfhost: route brand assets through /storage/ rewrite
Download 3 Tuxedo brand logos from Supabase Storage to local MinIO and
point the DB at portable /storage/... paths. Add a Next.js rewrite in
next.config.ts that proxies /storage/* to the configured MinIO endpoint.

Why: Next.js's image optimizer refuses to fetch upstream images whose
hostname resolves to a private IP. Local MinIO lives on 127.0.0.1, so
direct URLs (e.g. http://localhost:9000/...) get blocked with
"upstream image resolved to private ip [::1,127.0.0.1]".

The rewrite keeps URLs same-origin in the browser, so the optimizer's
private-IP check is bypassed. For production, set STORAGE_PUBLIC_URL to
the public MinIO endpoint and the same DB values work without change.

Also fix the same pattern in 3 client-rendered components that
hardcoded publicUrl(BUCKETS.BRAND_LOGOS, ...) for fallback assets:
- TuxedoVideoHero (hero video + Olathe Sweet dark logo)
- TimeTrackingFieldClient (field UI logo)
- tuxedo/about (about page logo)

email-service.ts keeps publicUrl() because Resend fetches URLs
server-side from its own network.

Documented the workflow and gotchas in docs/SUPABASE_DUMP_GUIDE.md.
2026-06-05 17:51:48 +00:00
tyler cbc8958b55 update dump guide with real lessons from 2026-06-05 capture
- aws-1 pooler (not aws-0)
- pg_dump 17 required for Supabase (PG 17.6 server)
- extensions schema + stubs needed for local PG 16
- auth.users stub for RLS policy references
- Re-apply is idempotent (errors are 'already exists')
2026-06-05 17:31:31 +00:00
tyler be226d3430 real Supabase schema + data loaded; cleanup synthesized placeholder
Major changes:
- Drop synthesized base schema (supabase/synthesized/000_base_schema.sql) —
  real pg_dump from Supabase is now the source of truth
- Switch better-auth from better-auth/minimal to better-auth (full)
- Add localhost:9000 (MinIO) to next.config images allowlist
- Add .data/, bin/ to gitignore (local runtime artifacts)
- Ignore supabase/captured/ (regenerate from Supabase as needed)

DB state after dump:
- 65 real tables (vs 70 synthesized)
- 252 functions
- 5 brands: Tuxedo Corn, Indian River Direct + 3 test brands
- 3 admin users, 1 communication template, 7 brand features

Pooler URL: aws-1-us-east-1.pooler.supabase.com:5432
  user: postgres.wnzkhezyhnfzhkhiflrp
  (NOT aws-0 — that one doesn't know this project)
2026-06-05 17:30:30 +00:00
tyler b433c79677 supabase dump & restore guide (runbook for after spend cap removed)
Covers:
- Connection test (direct vs pooler, IPv4 vs IPv6)
- pg_dump commands (schema-only and data-only)
- Restore steps to local DB
- Verification queries
- Schema-per-brand restructure (future work)
2026-06-05 16:45:37 +00:00
tyler a266203c07 synthesized base schema for local dev (60 base tables)
Temporary schema derived from migration column references. Provides
workable local structure BEFORE the real Supabase pg_dump arrives.

When brother removes the Supabase spend cap:
1. pg_dump the real Supabase schema to supabase/captured_schema.sql
2. pg_dump the data to supabase/captured_data.sql
3. Drop everything in public schema
4. Apply the captured schema + data
5. Drop the synthesized file

The synthesized schema is NOT for production.
2026-06-05 16:44:41 +00:00
tyler 3f4145e800 fix(sitemap): render at request time to avoid build-time DB dependency 2026-06-05 15:36:45 +00:00
tyler 553bfed070 fix(supabase): don't force mock mode for non-supabase.co URLs (PostgREST compat) 2026-06-05 15:24:17 +00:00
tyler 452eef7606 feat(deploy): bring up Docker stack + apply migrations in Gitea workflow 2026-06-05 15:22:38 +00:00
tyler c20538ef9f Add MinIO storage + replace Supabase Storage with S3 SDK
- 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
2026-06-05 15:17:21 +00:00
tyler 66d8cdf9b2 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.
2026-06-05 15:12:53 +00:00
tyler e41ce98b74 Fix workflow: use actual secrets, fix env file writing 2026-06-05 15:12:53 +00:00
200 changed files with 3127 additions and 7499 deletions
+34
View File
@@ -0,0 +1,34 @@
# --- Self-hosted Postgres (Docker) ---
POSTGRES_USER=routecommerce
POSTGRES_PASSWORD=routecommerce_dev_password
POSTGRES_DB=route_commerce
# Used by the app and push-migrations.js to talk to the local DB
DATABASE_URL=postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce?schema=public
# --- PostgREST (REST API) ---
# Server actions call `${NEXT_PUBLIC_API_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_API_URL=http://localhost:3001
NEXT_PUBLIC_API_ANON_KEY=local-postgrest-anon-key
POSTGREST_SERVICE_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 <img src=...>
NEXT_PUBLIC_STORAGE_BASE_URL=http://localhost:9000
# --- Better Auth ---
BETTER_AUTH_SECRET=REPLACE_ME_WITH_OPENSSL_RAND_HEX_32
BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
# --- App secrets ---
MINIMAX_API_KEY=
MINIMAX_BASE_URL=
+134 -10
View File
@@ -17,37 +17,161 @@ jobs:
with: with:
node-version: '22' node-version: '22'
- name: Start Docker stack
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
run: |
APP_DIR=/home/tyler/route-commerce
mkdir -p $APP_DIR
cd $APP_DIR
[ -f .env ] || cp .env.example .env
# Append production secrets to .env (overriding .env.example defaults)
{
echo "POSTGRES_USER=${POSTGRES_USER}"
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
echo "POSTGRES_DB=${POSTGRES_DB}"
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}"
} >> .env
docker compose up -d db postgrest minio minio_init
# Wait for Postgres healthcheck
for i in $(seq 1 30); do
if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then
echo "Postgres is ready"
break
fi
sleep 2
done
- name: Apply migrations
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
run: |
cd /home/tyler/route-commerce
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/migrations/000_preflight_supabase_compat.sql || true
[ -f supabase/captured_schema.sql ] && PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/captured_schema.sql || echo "captured_schema.sql not present, skipping"
for f in supabase/migrations/[0-9]*.sql; do
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -q -f "$f" || echo "FAIL: $f"
done
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
- name: Load env
run: cp /home/tyler/route-commerce/.env.production .env.production
- name: Build - name: Build
run: npm run build
env: env:
NODE_ENV: production NODE_ENV: production
DATABASE_URL: ${{ secrets.DATABASE_URL }}
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
NEXT_PUBLIC_BETTER_AUTH_URL: ${{ secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
run: npm run build
- name: Deploy - name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
NEXT_PUBLIC_BETTER_AUTH_URL: ${{ secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
PGRST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
run: | run: |
APP_DIR=/home/tyler/route-commerce APP_DIR=/home/tyler/route-commerce
mkdir -p $APP_DIR mkdir -p $APP_DIR
# Write env file from secrets (preserves existing .env for docker compose)
{
printf "DATABASE_URL=%s\n" "$DATABASE_URL"
printf "POSTGRES_USER=%s\n" "$POSTGRES_USER"
printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD"
printf "POSTGRES_DB=%s\n" "$POSTGRES_DB"
printf "MINIO_ROOT_USER=%s\n" "$MINIO_ROOT_USER"
printf "MINIO_ROOT_PASSWORD=%s\n" "$MINIO_ROOT_PASSWORD"
printf "POSTGREST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
printf "BETTER_AUTH_SECRET=%s\n" "$BETTER_AUTH_SECRET"
printf "BETTER_AUTH_URL=%s\n" "$BETTER_AUTH_URL"
printf "NEXT_PUBLIC_BETTER_AUTH_URL=%s\n" "$NEXT_PUBLIC_BETTER_AUTH_URL"
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY"
printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX"
printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL"
printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT"
printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI"
printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE"
printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL"
printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY"
printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY"
printf "STRIPE_WEBHOOK_SECRET=%s\n" "$STRIPE_WEBHOOK_SECRET"
printf "STRIPE_PUBLISHABLE_KEY=%s\n" "$STRIPE_PUBLISHABLE_KEY"
printf "RESEND_API_KEY=%s\n" "$RESEND_API_KEY"
printf "RESEND_WEBHOOK_SECRET=%s\n" "$RESEND_WEBHOOK_SECRET"
printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY"
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL"
printf "FROM_EMAIL=%s\n" "$FROM_EMAIL"
} > $APP_DIR/.env.production
# Copy build output and required files # Copy build output and required files
rsync -a --delete .next/ $APP_DIR/.next/ rsync -a --delete .next/ $APP_DIR/.next/
rsync -a --delete public/ $APP_DIR/public/ rsync -a --delete public/ $APP_DIR/public/
cp package.json package-lock.json $APP_DIR/ cp package.json $APP_DIR/
cp docker-compose.yml $APP_DIR/
cp -r supabase/ $APP_DIR/
cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true
# Install production deps only # Install production deps only
cd $APP_DIR cd $APP_DIR
npm install --omit=dev 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 # Start or restart PM2 process
if pm2 describe route-commerce > /dev/null 2>&1; then if pm2 describe route-commerce > /dev/null 2>&1; then
pm2 restart route-commerce pm2 restart route-commerce
+17 -1
View File
@@ -41,4 +41,20 @@ supabase/.temp/
# IDE / local config # IDE / local config
.mcp.json .mcp.json
.env*
# Docker / self-hosted Postgres
db_data/
# Captured Supabase data (re-pull from Supabase as needed)
supabase/captured/captured_data.sql.gz
supabase/captured/captured_schema.sql
# Local data and binaries
.data/
bin/postgrest
bin/minio
bin/mc
bin/postgrest-proxy.js
bin/postgrest.tar.xz
# Captured Supabase dump (regenerate from Supabase with the guide in docs/SUPABASE_DUMP_GUIDE.md)
supabase/captured/
@@ -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/<bucket>/<key>`, 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:<service-role-pw>@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.
Binary file not shown.
+83
View File
@@ -0,0 +1,83 @@
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
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
+312
View File
@@ -0,0 +1,312 @@
# Supabase Dump & Restore Guide
This guide is the runbook for capturing the real Supabase schema and data
and restoring it to a local Postgres database. It documents the exact
steps that worked when the spend cap was removed on 2026-06-05.
## Connection (this works from the dev box)
The Supabase project `wnzkhezyhnfzhkhiflrp` (route-commerce) is hosted in
**East US (North Virginia)**. The dev box has no IPv6, so we must use
the **Supavisor pooler** (IPv4 only) — and it's on **`aws-1`**, not `aws-0`.
```bash
# Working pooler URL (from supabase/.temp/pooler-url)
postgresql://postgres.wnzkhezyhnfzhkhiflrp:YLKzP9jz2yqop7jr@aws-1-us-east-1.pooler.supabase.com:5432/postgres
```
The `aws-0-us-east-1.pooler.supabase.com` hostname resolves over IPv4
but Supavisor returns "tenant/user not found" because the project lives
on `aws-1`. The correct region number is non-obvious — always check
`supabase/.temp/pooler-url` for the actual endpoint.
The direct hostname `db.wnzkhezyhnfzhkhiflrp.supabase.co` only resolves
over IPv6, which is unreachable from this dev box.
## pg_dump version
Supabase runs **PostgreSQL 17.6**. Local pg must be ≥ 17 to dump cleanly.
The dev box had pg 16 by default — install pg 17 client:
```bash
echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt/ noble-pgdg main" \
| sudo tee /etc/apt/sources.list.d/pgdg.list
sudo apt-get update
sudo apt-get install -y --allow-unauthenticated postgresql-client-17
# Use /usr/lib/postgresql/17/bin/pg_dump explicitly (or update PATH)
```
## Capture Schema
```bash
export PGPASSWORD="YLKzP9jz2yqop7jr"
export PATH="/usr/lib/postgresql/17/bin:$PATH"
mkdir -p supabase/captured
pg_dump \
--host=aws-1-us-east-1.pooler.supabase.com \
--port=5432 \
--username=postgres.wnzkhezyhnfzhkhiflrp \
--dbname=postgres \
--schema-only \
--no-owner \
--no-privileges \
--no-acl \
--exclude-schema=auth \
--exclude-schema=storage \
--exclude-schema=realtime \
--exclude-schema=supabase_functions \
--exclude-schema=graphql \
--exclude-schema=graphql_public \
--exclude-schema=pgsodium \
--exclude-schema=pgsodium_masks \
--exclude-schema=extensions \
--exclude-schema=pgbouncer \
--exclude-schema=supabase_migrations \
--exclude-schema=net \
--exclude-schema=vault \
--file=supabase/captured/captured_schema.sql
```
Captured schema is ~540KB, 65 tables, 252 functions.
## Capture Data
```bash
pg_dump \
--host=aws-1-us-east-1.pooler.supabase.com \
--port=5432 \
--username=postgres.wnzkhezyhnfzhkhiflrp \
--dbname=postgres \
--data-only \
--no-owner \
--no-privileges \
--no-acl \
--disable-triggers \
--exclude-schema=auth \
--exclude-schema=storage \
--exclude-schema=realtime \
--exclude-schema=supabase_functions \
--exclude-schema=graphql \
--exclude-schema=graphql_public \
--exclude-schema=pgsodium \
--exclude-schema=pgsodium_masks \
--exclude-schema=extensions \
--exclude-schema=pgbouncer \
--exclude-schema=supabase_migrations \
--exclude-schema=net \
--exclude-schema=vault \
--file=supabase/captured/captured_data.sql
```
Captured data is ~130KB for this project's current size.
## Restore to Local DB (PostgreSQL 16)
PostgreSQL 16 doesn't support `transaction_timeout` (added in PG 17) and
doesn't have the `supabase_vault` extension. Pre-create the stub objects:
```bash
export PGPASSWORD=routecommerce_dev_password
psql -U routecommerce -h 127.0.0.1 -d route_commerce <<'SQL'
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO routecommerce;
-- extensions schema (referenced by dump as extensions.uuid_generate_v4())
CREATE SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS "pgcrypto" SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" SCHEMA extensions;
-- Stub functions in extensions schema (real Supabase has pgcrypto.crypt etc.)
CREATE OR REPLACE FUNCTION extensions.uuid_generate_v4()
RETURNS uuid LANGUAGE sql AS $$ SELECT gen_random_uuid(); $$;
CREATE OR REPLACE FUNCTION extensions.gen_salt(text)
RETURNS text LANGUAGE sql AS $$ SELECT '$2a$06$' || repeat('A', 53); $$;
CREATE OR REPLACE FUNCTION extensions.crypt(text, text)
RETURNS text LANGUAGE sql AS $$ SELECT $1; $$;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA extensions TO routecommerce;
-- auth stub (we excluded auth schema from dump, but RLS policies reference auth.uid())
CREATE SCHEMA IF NOT EXISTS auth;
CREATE TABLE IF NOT EXISTS auth.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT,
raw_user_meta_data JSONB,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE OR REPLACE FUNCTION auth.uid() RETURNS UUID
LANGUAGE sql STABLE AS $$ SELECT NULL::UUID; $$;
GRANT USAGE ON SCHEMA auth TO routecommerce;
GRANT ALL ON auth.users TO routecommerce;
GRANT EXECUTE ON FUNCTION auth.uid() TO routecommerce;
-- Drop Supabase-specific publications (we don't have realtime / vault)
DROP PUBLICATION IF EXISTS supabase_realtime;
DROP PUBLICATION IF EXISTS supabase_realtime_messages_publication;
SQL
```
Strip the PG17-only `SET transaction_timeout` line from the dump:
```bash
sed -i 's/^SET transaction_timeout = 0;$/-- transaction_timeout requires PG17 (we are on PG16); skipped/' \
supabase/captured/captured_schema.sql
```
Apply schema and data (idempotent — re-runs are safe):
```bash
psql -U routecommerce -h 127.0.0.1 -d route_commerce -v ON_ERROR_STOP=0 \
-f supabase/captured/captured_schema.sql 2>&1 | tail -20
psql -U routecommerce -h 127.0.0.1 -d route_commerce -v ON_ERROR_STOP=0 \
-f supabase/captured/captured_data.sql 2>&1 | tail -20
```
Most errors on re-run are "already exists" — that's expected because the
dump is idempotent for CREATE statements (we used `IF NOT EXISTS` where
possible, but pg_dump doesn't add it for everything).
## Verify
```bash
psql -U routecommerce -h 127.0.0.1 -d route_commerce -c "SELECT count(*) FROM pg_tables WHERE schemaname='public';"
# Expect: 65
psql -U routecommerce -h 127.0.0.1 -d route_commerce -c "SELECT count(*) FROM pg_proc WHERE pronamespace=(SELECT oid FROM pg_namespace WHERE nspname='public');"
# Expect: ~250+
psql -U routecommerce -h 127.0.0.1 -d route_commerce -c "SELECT name, slug, created_at FROM brands ORDER BY created_at;"
# Expect 5 brands: Tuxedo Corn, Indian River Direct, Sunrise Farms, Green Valley Organics, Orchard Fresh
```
## What Didn't Work (don't try these)
| Approach | Why it failed |
|---|---|
| `psql ... db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` | Hostname is IPv6-only, dev box has no IPv6 |
| `aws-0-us-east-1.pooler.supabase.com` | "tenant/user not found" — wrong region (project is on `aws-1`) |
| `pg_dump 16.x` against PG 17 server | "server version mismatch" — fatal error |
| `supabase db dump --linked` | Requires Docker (we don't have it) |
## Notes
- The captured data includes 3 test brands (Sunrise, Green Valley, Orchard)
created on 2026-06-03 — these were test data the user added during the
migration work, not real customers. They can be deleted safely.
- Tuxedo Corn and Indian River Direct are the real production brands.
- The schema dump is committed to git at `supabase/captured/captured_schema.sql.gz`
for reproducibility. The data dump is gitignored (regenerate as needed).
- After dump, the local PostgREST needs a schema cache reload — restart
the postgrest process or it'll serve stale metadata for ~30 seconds.
## Post-restore: clean up test brands
The captured production data includes 3 test brands with hardcoded
sequential UUIDs (`a1b2c3d4-…`, `b2c3d4e5-…`, `c3d4e5f6-…`) created
during the migration work. They have no related rows in any of the 38
tables that FK to `brands.id`, so deletion is safe (CASCADE / NO ACTION
on zero rows is a no-op).
```sql
BEGIN;
DELETE FROM brands
WHERE slug IN ('sunrise-farms', 'green-valley', 'orchard-fresh');
-- expect DELETE 3
COMMIT;
```
Verify the real brands remain:
```sql
SELECT name, slug, plan_tier FROM brands ORDER BY created_at;
-- Tuxedo Corn | tuxedo | starter
-- Indian River Direct | indian-river-direct | starter
```
## Migrating Brand Assets (Supabase Storage → MinIO)
Brand logos and other Storage files are NOT in the Postgres dump. The
storage layer is now MinIO (S3-compatible) instead of Supabase Storage.
### Download assets from Supabase
```bash
# Make sure the target buckets exist in MinIO (one-time)
mc mb --ignore-existing local/brand-logos local/videos \
local/product-images local/contacts-imports \
local/water-photos
# Pull each asset via the public URL. Path is <bucket>/<key> after the
# /storage/v1/object/public/ prefix in the Supabase URL.
mkdir -p .data/assets
BRAND_ID="<your-brand-uuid>"
for fname in logo.png olathe-sweet-logo.png olathe-sweet-logo-dark.png; do
curl -sf -o ".data/assets/$fname" \
"https://<project-ref>.supabase.co/storage/v1/object/public/brand-logos/$BRAND_ID/$fname"
mc cp ".data/assets/$fname" "local/brand-logos/$BRAND_ID/$fname"
done
```
### Point the DB at MinIO (portable /storage/... paths)
After capture, the `brand_settings.logo_url` etc. values still point at
the Supabase URL. Replace the base with a relative `/storage/` path so
the Next.js rewrite in `next.config.ts` can route to whichever MinIO
endpoint is configured per environment (dev → `localhost:9000`, prod →
`storage.route.crispygoat.com`).
```sql
UPDATE brand_settings
SET
logo_url = REPLACE(logo_url, 'https://<project-ref>.supabase.co/storage/v1/object/public', '/storage'),
logo_url_dark = REPLACE(logo_url_dark, 'https://<project-ref>.supabase.co/storage/v1/object/public', '/storage'),
olathe_sweet_logo_url = REPLACE(olathe_sweet_logo_url, 'https://<project-ref>.supabase.co/storage/v1/object/public', '/storage'),
olathe_sweet_logo_url_dark = REPLACE(olathe_sweet_logo_url_dark, 'https://<project-ref>.supabase.co/storage/v1/object/public', '/storage'),
hero_image_url = REPLACE(hero_image_url, 'https://<project-ref>.supabase.co/storage/v1/object/public', '/storage');
```
### Why a rewrite instead of pointing at MinIO directly
Next.js's image optimizer (`/_next/image?url=...`) refuses to fetch
upstream images whose hostname resolves to a private IP. Local MinIO is
on `127.0.0.1` / `::1`, so URLs like `http://localhost:9000/...` get
blocked:
```
upstream image http://localhost:9000/... resolved to private ip
["::1","127.0.0.1"]
```
The rewrite in `next.config.ts` resolves `/storage/*` to the configured
MinIO base URL server-side, so the browser sees a same-origin URL and
the optimizer's private-IP check is bypassed:
```ts
async rewrites() {
const storageBase = process.env.STORAGE_PUBLIC_URL || "http://localhost:9000";
return [{ source: "/storage/:path*", destination: `${storageBase}/:path*` }];
}
```
For production, set `STORAGE_PUBLIC_URL` to the public MinIO endpoint
(e.g., `https://storage.route.crispygoat.com`) and the same DB values
work without modification.
### Hardcoded brand asset URLs in client components
A few components used `publicUrl(BUCKETS.BRAND_LOGOS, ...)` to build a
MinIO URL at module load (used as a fallback before client-side data
loads). Switch these to `/storage/...` paths so the rewrite covers them:
- `src/components/storefront/TuxedoVideoHero.tsx` — hero video + Olathe Sweet dark logo
- `src/components/time-tracking/TimeTrackingFieldClient.tsx` — field UI logo
- `src/app/tuxedo/about/page.tsx` — about page logo
`src/lib/email-service.ts` should keep using `publicUrl(...)` because
Resend fetches the URL server-side from its own network — relative
paths won't work for emails.
+22 -28
View File
@@ -1,4 +1,5 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000"; 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=/" // Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
const devSession = request.cookies.get("dev_session")?.value; const devSession = request.cookies.get("dev_session")?.value;
const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee"; 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) { if (isDevMode) {
// Dev session only valid in development authed = true;
authUid = DEV_UID; } else if (hasSession) {
} else if (rcAuthUid) { authed = true;
// rc_auth_uid is set by /api/login — treat as authenticated
authUid = rcAuthUid;
} }
// No rc_auth_uid in production → authUid stays null → redirect to /login
const isAdmin = request.nextUrl.pathname.startsWith("/admin"); const isAdmin = request.nextUrl.pathname.startsWith("/admin");
const isLogin = request.nextUrl.pathname === "/login"; const isLogin = request.nextUrl.pathname === "/login";
if (isAdmin && !authUid) { if (isAdmin && !authed) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; // Auto-login for demo: no auth cookie present
// 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;
}
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
url.pathname = "/login"; url.pathname = "/admin";
return NextResponse.redirect(url); 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(); const url = request.nextUrl.clone();
url.pathname = "/admin"; url.pathname = "/admin";
return NextResponse.redirect(url); return NextResponse.redirect(url);
@@ -61,4 +55,4 @@ export const config = {
"/admin", "/admin",
"/login", "/login",
], ],
}; };
+29 -1
View File
@@ -19,6 +19,24 @@ const nextConfig: NextConfig = {
protocol: "https", protocol: "https",
hostname: "picsum.photos", hostname: "picsum.photos",
}, },
// Local self-hosted MinIO (replaces Supabase Storage)
{
protocol: "http",
hostname: "localhost",
port: "9000",
pathname: "/**",
},
{
protocol: "http",
hostname: "127.0.0.1",
port: "9000",
pathname: "/**",
},
// Production MinIO behind route.crispygoat.com
{
protocol: "https",
hostname: "storage.route.crispygoat.com",
},
], ],
formats: ["image/avif", "image/webp"], formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
@@ -88,8 +106,18 @@ const nextConfig: NextConfig = {
// Rewrites for API proxy // Rewrites for API proxy
async rewrites() { async rewrites() {
// Storage proxy: /storage/* -> MinIO at the same path
// Lets brand assets and product images use portable relative URLs
// (e.g. /storage/brand-logos/<id>/logo.png) in the DB, with Next.js
// proxying to whichever MinIO endpoint is configured for the environment.
// Avoids the next/image "upstream resolved to private ip" block on
// localhost MinIO by keeping the upstream fetch on the server side.
const storageBase = process.env.STORAGE_PUBLIC_URL || "http://localhost:9000";
return [ return [
// Add any necessary rewrites here {
source: "/storage/:path*",
destination: `${storageBase}/:path*`,
},
]; ];
}, },
+5 -2
View File
@@ -15,17 +15,19 @@
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.96.0", "@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", "@clerk/nextjs": "^7.4.2",
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
"@gsap/react": "^2.1.2", "@gsap/react": "^2.1.2",
"@sentry/nextjs": "^10.55.0", "@sentry/nextjs": "^10.55.0",
"@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.105.3",
"@upstash/ratelimit": "^2.0.8", "@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0", "@upstash/redis": "^1.38.0",
"better-auth": "^1.6.14",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
"gsap": "^3.15.0", "gsap": "^3.15.0",
"kysely": "^0.29.2",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next": "^16.2.6", "next": "^16.2.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -48,6 +50,7 @@
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
+3 -14
View File
@@ -1,15 +1,6 @@
"use server"; "use server";
import { createClient as createServiceClient } from "@supabase/supabase-js"; import { svcRpc } from "@/lib/svc-fetch";
function getServiceClient() {
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set");
return createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
roleKey,
);
}
type AdminActionPayload = { type AdminActionPayload = {
action_type: "create" | "update" | "delete"; action_type: "create" | "update" | "delete";
@@ -30,8 +21,7 @@ type UserActivityPayload = {
export async function logAdminAction(payload: AdminActionPayload): Promise<void> { export async function logAdminAction(payload: AdminActionPayload): Promise<void> {
try { try {
const service = getServiceClient(); await svcRpc("log_admin_action", {
await service.rpc("log_admin_action", {
p_payload: { p_payload: {
action_type: payload.action_type, action_type: payload.action_type,
admin_id: payload.admin_id ?? null, admin_id: payload.admin_id ?? null,
@@ -48,8 +38,7 @@ export async function logAdminAction(payload: AdminActionPayload): Promise<void>
export async function logUserActivity(payload: UserActivityPayload): Promise<void> { export async function logUserActivity(payload: UserActivityPayload): Promise<void> {
try { try {
const service = getServiceClient(); await svcRpc("log_user_activity", {
await service.rpc("log_user_activity", {
p_payload: { p_payload: {
user_id: payload.user_id, user_id: payload.user_id,
activity_type: payload.activity_type, activity_type: payload.activity_type,
+31 -53
View File
@@ -1,63 +1,41 @@
"use server"; "use server";
import { createServerClient } from "@supabase/ssr"; import { Pool } from "pg";
import { cookies } from "next/headers"; import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001"; const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> { export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> {
const cookieStore = await cookies(); try {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Upsert dev platform_admin record
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; 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(); if (res.rows.length === 0) {
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, { return { success: false, error: "Failed to upsert dev admin" };
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 };
} }
}
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 };
}
} }
+3 -8
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { createClient as createServiceClient } from "@supabase/supabase-js"; import { svcRpc } from "@/lib/svc-fetch";
export async function updatePasswordAction( export async function updatePasswordAction(
newPassword: string newPassword: string
@@ -15,16 +15,11 @@ export async function updatePasswordAction(
return { error: "Not authenticated. Please log in again." }; return { error: "Not authenticated. Please log in again." };
} }
const service = createServiceClient( const { error } = await svcRpc("update_user_password", {
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const { error } = await service.rpc("update_user_password", {
p_user_id: uid, p_user_id: uid,
p_password: newPassword, p_password: newPassword,
}); });
if (error) return { error: error.message }; if (error) return { error: error.message };
return {}; return {};
} }
+31
View File
@@ -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<UpdateAdminProfileResult> {
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 };
}
}
+7 -21
View File
@@ -1,15 +1,6 @@
"use server"; "use server";
import { createClient as createServiceClient } from "@supabase/supabase-js"; import * as authAdmin from "@/lib/auth-admin";
function getServiceClient() {
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY not set");
return createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
roleKey,
);
}
export type ResetAdminPasswordResult = export type ResetAdminPasswordResult =
| { success: true; tempPassword: string } | { success: true; tempPassword: string }
@@ -24,24 +15,19 @@ export async function resetAdminPassword(
email: string, email: string,
newPassword: string newPassword: string
): Promise<ResetAdminPasswordResult> { ): Promise<ResetAdminPasswordResult> {
const service = getServiceClient();
// Look up auth user by email // Look up auth user by email
const { data: authUsers, error: listError } = await service.auth.admin.listUsers(); const { data: users, error: listError } = await authAdmin.listUsers({ searchValue: email });
if (listError || !authUsers?.users) { if (listError) {
return { success: false, error: "Could not list users: " + listError?.message }; return { success: false, error: "Could not list users: " + listError.message };
} }
const authUser = authUsers.users.find((u) => u.email?.toLowerCase() === email.toLowerCase()); const authUser = (users ?? []).find((u) => u.email?.toLowerCase() === email.toLowerCase());
if (!authUser) { if (!authUser) {
return { success: false, error: "No auth user found for that email address." }; return { success: false, error: "No auth user found for that email address." };
} }
// Update password via service role // Update password via the better-auth admin wrapper (uses `update_user_password` RPC internally)
const { error: updateError } = await service.auth.admin.updateUserById( const { error: updateError } = await authAdmin.setUserPassword(authUser.id, newPassword);
authUser.id,
{ password: newPassword, email_confirm: true }
);
if (updateError) { if (updateError) {
return { success: false, error: updateError.message }; return { success: false, error: updateError.message };
+93 -203
View File
@@ -1,14 +1,10 @@
"use server"; "use server";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { createServerClient } from "@supabase/ssr"; import { api as publicApi } from "@/lib/api";
import { NextRequest } from "next/server"; import { auth } from "@/lib/auth";
import { NextResponse } from "next/server"; import { svcApi, svcRpc } from "@/lib/svc-fetch";
import { createClient as createServiceClient } from "@supabase/supabase-js"; import * as authAdmin from "@/lib/auth-admin";
import { supabase as publicSupabase } from "@/lib/supabase";
import { getMockTableData, mockBrands } from "@/lib/mock-data";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type AdminUserRow = { export type AdminUserRow = {
id: string; id: string;
@@ -75,66 +71,44 @@ export type UpdateAdminUserInput = {
phone_number?: string | null; phone_number?: string | null;
}; };
// ─── SSR client for authenticated requests ───────────────────────────────── // ─── Auth helper: validate session via better-auth ──────────────────────────
async function getAuthClient() { /**
const cookieStore = await cookies(); * Read rc_auth_uid from the raw HTTP Cookie header. This is set by the
* Emergency Force Login flow and acts as a dev-mode bypass for normal auth.
*/
async function readRcAuthUid(): Promise<string | null> {
const headerStore = await headers(); const headerStore = await headers();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const request = new NextRequest("http://localhost/admin", { headers: new Headers() });
const response = NextResponse.next({ request });
// Read rc_auth_uid from the raw HTTP Cookie header (document.cookie sets
// cookies that arrive in the header but NOT in next/headers cookies()).
const cookieHeader = headerStore.get("cookie") || ""; const cookieHeader = headerStore.get("cookie") || "";
const allCookies = cookieHeader.split(";").map(c => c.trim()); const allCookies = cookieHeader.split(";").map(c => c.trim());
const rcUidCookie = allCookies.find(c => c.startsWith("rc_auth_uid=")); const rcUidCookie = allCookies.find(c => c.startsWith("rc_auth_uid="));
const rcAuthUid = rcUidCookie ? rcUidCookie.split("=")[1] : null; return rcUidCookie ? rcUidCookie.split("=")[1] : null;
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));
},
},
});
return { supabase, response, rcAuthUid };
} }
/**
* Authenticated RPC call. Validates the session via better-auth, then calls
* the named RPC using the service-role client.
*
* In development, the DEV_FORCE_UID cookie bypasses the auth check (the
* Emergency Force Login path is itself authenticated upstream).
*/
async function callRpcWithAuth<T>(fn: string, params: Record<string, unknown>): Promise<{ data: T | null; error: string | null }> { async function callRpcWithAuth<T>(fn: string, params: Record<string, unknown>): Promise<{ data: T | null; error: string | null }> {
const { supabase, rcAuthUid } = await getAuthClient();
// Dev force-login UID bypasses Supabase auth entirely
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001"; const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
const rcAuthUid = await readRcAuthUid();
if (rcAuthUid === DEV_FORCE_UID) { if (rcAuthUid === DEV_FORCE_UID) {
return { data: null, error: null }; // let the action proceed without auth check return { data: null, error: null }; // dev force-login bypass
} }
const { data: userData, error: userError } = await supabase.auth.getUser(); const headerStore = await headers();
if (userError || !userData.user) { const session = await auth.api.getSession({ headers: headerStore });
if (!session?.user) {
return { data: null, error: "Not authenticated" }; return { data: null, error: "Not authenticated" };
} }
const { data, error } = await supabase.rpc(fn, params as Record<string, unknown>);
if (error) { /* RPC error handled silently */ } const { data, error } = await svcRpc<T>(fn, params);
return { data: data as T, error: error ? error.message : null }; return { data: data as T, error: error ? error.message : null };
} }
// ─── Service role client (server-only, never exposed to browser) ───────────
function getServiceClient() {
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!roleKey) {
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set. Cannot use service role in dev path.");
}
return createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
roleKey,
);
}
// ─── Dev-only path — uses service role to create auth user + admin_users ── // ─── Dev-only path — uses service role to create auth user + admin_users ──
async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> { async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
@@ -147,27 +121,21 @@ async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user:
return { user: null, error: "Not authenticated" }; return { user: null, error: "Not authenticated" };
} }
const service = getServiceClient(); // Create auth user with the provided password (better-auth signUpEmail)
const { data: authUser, error: authError } = await authAdmin.createUser({
// Create auth user with the provided password
const { data: authUser, error: authError } = await service.auth.admin.createUser({
email: input.email, email: input.email,
password: input.password, password: input.password,
email_confirm: true, name: input.display_name || input.email.split("@")[0],
user_metadata: {
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
},
}); });
if (authError || !authUser.user) { if (authError || !authUser) {
return { user: null, error: authError?.message ?? "Failed to create auth user" }; return { user: null, error: authError?.message ?? "Failed to create auth user" };
} }
// Insert into admin_users // Insert into admin_users (service role bypasses RLS)
const { data: inserted, error: insertError } = await service const { data: inserted, error: insertError } = await svcApi
.from("admin_users") .from("admin_users")
.insert({ .insert({
user_id: authUser.user.id, user_id: authUser.id,
role: input.role, role: input.role,
brand_id: input.brand_id, brand_id: input.brand_id,
display_name: input.display_name || input.email.split("@")[0], display_name: input.display_name || input.email.split("@")[0],
@@ -185,11 +153,14 @@ async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user:
must_change_password: input.mustChangePassword ?? true, must_change_password: input.mustChangePassword ?? true,
}) })
.select() .select()
.single(); .single() as { data: any; error: { message: string } | null };
if (insertError) { if (insertError) {
return { user: null, error: insertError.message }; return { user: null, error: insertError.message };
} }
if (!inserted) {
return { user: null, error: "Insert returned no row" };
}
// Send welcome email // Send welcome email
try { try {
@@ -263,27 +234,24 @@ function mapUserRow(row: Record<string, unknown>): AdminUserRow {
// ─── Dev path helpers (service role, local only) ─────────────────────────── // ─── Dev path helpers (service role, local only) ───────────────────────────
async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUserRow[]; error: string | null }> { async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
const service = getServiceClient();
// Ensure caller has an admin_users record // Ensure caller has an admin_users record
if (callerUid) { if (callerUid) {
const { data: existing } = await service const { data: existing } = await svcApi
.from("admin_users") .from("admin_users")
.select("id") .select("id")
.eq("user_id", callerUid) .eq("user_id", callerUid)
.maybeSingle(); .maybeSingle() as { data: any };
if (!existing) { if (!existing) {
// auto-creating admin_users for uid // auto-creating admin_users for uid
const { data: authData } = await service.auth.admin.listUsers(); const { data: listData } = await authAdmin.listUsers({ searchValue: undefined });
const authUser = authData?.users?.find((u) => u.id === callerUid); const authUser = (listData ?? []).find((u: any) => u.id === callerUid);
const meta = (authUser as { user_metadata?: Record<string, unknown> })?.user_metadata; await svcApi.from("admin_users").insert({
await service.from("admin_users").insert({
user_id: callerUid, user_id: callerUid,
role: "platform_admin", role: "platform_admin",
brand_id: null, brand_id: null,
display_name: (meta?.display_name as string | null) ?? authUser?.email?.split("@")[0] ?? "Admin", display_name: authUser?.name ?? authUser?.email?.split("@")[0] ?? "Admin",
phone_number: (meta?.phone_number as string | null) ?? null, phone_number: null,
can_manage_products: true, can_manage_products: true,
can_manage_stops: true, can_manage_stops: true,
can_manage_orders: true, can_manage_orders: true,
@@ -300,7 +268,7 @@ async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUser
} }
// Fetch all admin_users rows (no RLS for service role) // Fetch all admin_users rows (no RLS for service role)
const { data: adminRows, error: adminError } = await service const { data: adminRows, error: adminError } = await svcApi
.from("admin_users") .from("admin_users")
.select(` .select(`
id, user_id, role, brand_id, active, must_change_password, created_at, last_login, id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
@@ -308,57 +276,30 @@ async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUser
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports, can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports,
brands (name) brands (name)
`) `)
.order("created_at", { ascending: false }); .order("created_at", { ascending: false }) as { data: any; error: any };
if (adminError) return { users: [], error: adminError.message }; if (adminError) return { users: [], error: adminError.message };
// Fetch auth user details via service role admin API // Fetch auth user details via better-auth admin list
const { data: authData, error: authError } = await service.auth.admin.listUsers(); const { data: listData, error: authError } = await authAdmin.listUsers({ searchValue: undefined });
if (authError) return { users: [], error: authError.message }; if (authError) return { users: [], error: authError.message };
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {}; const authMap: Record<string, { email: string; display_name: string | null }> = {};
(authData?.users ?? []).forEach((u) => { (listData ?? []).forEach((u: any) => {
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
authMap[user.id] = {
email: user.email ?? "",
display_name: (user.user_metadata?.display_name as string | null) ?? (user.user_metadata?.full_name as string | null) ?? null,
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
};
});
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
const r = row as Record<string, unknown> & { brands?: { name?: string } };
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
return {
...mapUserRow(r),
email: authInfo.email || "No Email",
display_name: authInfo.display_name ?? null,
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
brand_name: r.brands?.name ?? null,
};
});
return { users, error: null };
}
function buildUsersFromRows(adminRows: Record<string, unknown>[], authUsers: { id: string; email?: string; user_metadata?: Record<string, unknown> }[]): { users: AdminUserRow[]; error: string | null } {
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
(authUsers ?? []).forEach((u) => {
authMap[u.id] = { authMap[u.id] = {
email: u.email ?? "", email: u.email ?? "",
display_name: (u.user_metadata?.display_name as string | null) ?? (u.user_metadata?.full_name as string | null) ?? null, display_name: u.name ?? null,
phone_number: (u.user_metadata?.phone_number as string | null) ?? null,
}; };
}); });
const users: AdminUserRow[] = adminRows.map((row) => { const users: AdminUserRow[] = (adminRows ?? []).map((row: any) => {
const r = row as Record<string, unknown> & { brands?: { name?: string } }; const r = row as Record<string, unknown> & { brands?: { name?: string } };
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null }; const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null };
return { return {
...mapUserRow(r), ...mapUserRow(r),
email: authInfo.email || "No Email", email: authInfo.email || "No Email",
display_name: authInfo.display_name ?? null, display_name: authInfo.display_name ?? null,
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null, phone_number: (r.phone_number as string | null) ?? null,
brand_name: r.brands?.name ?? null, brand_name: r.brands?.name ?? null,
}; };
}); });
@@ -371,15 +312,6 @@ function buildUsersFromRows(adminRows: Record<string, unknown>[], authUsers: { i
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001"; const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> { export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
let filteredUsers = mockUsers;
if (brandId) {
filteredUsers = mockUsers.filter(u => u.brand_id === brandId);
}
return { users: filteredUsers, error: null };
}
const cookieStore = await cookies(); const cookieStore = await cookies();
const headerStore = await headers(); const headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value; const devSession = cookieStore.get("dev_session")?.value;
@@ -390,7 +322,7 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// In development mode: ALL requests with a valid rc_auth_uid use the dev/service path. // In development mode: ALL requests with a valid rc_auth_uid use the dev/service path.
// This includes real Supabase auth users — bypassing supabase.auth.getUser JWT validation. // This includes real Supabase auth users — bypassing api.auth.getUser JWT validation.
if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) { if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) {
return devListAdminUsers(rcAuthUid); return devListAdminUsers(rcAuthUid);
} }
@@ -406,38 +338,8 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
// Production path: try authenticated RPC first, fall back to service role if not authenticated // Production path: try authenticated RPC first, fall back to service role if not authenticated
const result = await callRpcWithAuth<AdminUserRow[]>("get_admin_users", { p_brand_id: brandId ?? null }); const result = await callRpcWithAuth<AdminUserRow[]>("get_admin_users", { p_brand_id: brandId ?? null });
if (result.error === "Not authenticated" && rcAuthUid) { if (result.error === "Not authenticated" && rcAuthUid) {
// No Supabase session token in browser — use service role with rc_auth_uid // No better-auth session token in browser — use service role via devListAdminUsers
const service = getServiceClient(); return devListAdminUsers(rcAuthUid);
const { data: adminRows, error: adminError } = await service
.from("admin_users")
.select(`id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
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,
brands (name)`)
.order("created_at", { ascending: false });
if (adminError) return { users: [], error: adminError.message };
const { data: authData } = await service.auth.admin.listUsers();
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
(authData?.users ?? []).forEach((u) => {
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
authMap[user.id] = {
email: user.email ?? "",
display_name: (user.user_metadata?.display_name as string | null) ?? null,
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
};
});
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
const r = row as Record<string, unknown> & { brands?: { name?: string } };
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
return {
...mapUserRow(r),
email: authInfo.email || "No Email",
display_name: authInfo.display_name ?? null,
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
brand_name: r.brands?.name ?? null,
};
});
return { users, error: null };
} }
return { users: result.data ?? [], error: result.error }; return { users: result.data ?? [], error: result.error };
} }
@@ -466,27 +368,21 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
// Production path — use service role directly (bypasses Supabase JWT auth) // Production path — use service role directly (bypasses Supabase JWT auth)
// rc_auth_uid cookie proves the admin is logged in; service role creates the account // rc_auth_uid cookie proves the admin is logged in; service role creates the account
if (rcAuthUid) { if (rcAuthUid) {
const service = getServiceClient(); // Create auth user via better-auth
const { data: authUser, error: authError } = await authAdmin.createUser({
// Create auth user
const { data: authUser, error: authError } = await service.auth.admin.createUser({
email: input.email, email: input.email,
password: input.password, password: input.password,
email_confirm: true, name: input.display_name || input.email.split("@")[0],
user_metadata: {
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
},
}); });
if (authError || !authUser.user) { if (authError || !authUser) {
return { user: null, error: authError?.message ?? "Failed to create auth user" }; return { user: null, error: authError?.message ?? "Failed to create auth user" };
} }
// Insert into admin_users // Insert into admin_users via service-role PostgREST
const { data: inserted, error: insertError } = await service const { data: inserted, error: insertError } = await svcApi
.from("admin_users") .from("admin_users")
.insert({ .insert({
user_id: authUser.user.id, user_id: authUser.id,
role: input.role, role: input.role,
brand_id: input.brand_id, brand_id: input.brand_id,
display_name: input.display_name || input.email.split("@")[0], display_name: input.display_name || input.email.split("@")[0],
@@ -506,7 +402,9 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
.select() .select()
.single(); .single();
if (insertError) return { user: null, error: insertError.message }; if (insertError || !inserted) {
return { user: null, error: insertError?.message ?? "Insert returned no row" };
}
// Send welcome email // Send welcome email
try { try {
@@ -525,26 +423,26 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
return { return {
user: { user: {
id: inserted.id, id: inserted.id ?? "",
user_id: inserted.user_id, user_id: inserted.user_id ?? authUser.id,
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0], display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
email: input.email, email: input.email,
phone_number: inserted.phone_number ?? input.phone_number ?? null, phone_number: inserted.phone_number ?? input.phone_number ?? null,
role: inserted.role, role: (inserted.role as AdminUserRow["role"]) ?? "store_employee",
brand_id: inserted.brand_id, brand_id: inserted.brand_id ?? null,
brand_name: null, brand_name: null,
can_manage_products: inserted.can_manage_products, can_manage_products: inserted.can_manage_products ?? false,
can_manage_stops: inserted.can_manage_stops, can_manage_stops: inserted.can_manage_stops ?? false,
can_manage_orders: inserted.can_manage_orders, can_manage_orders: inserted.can_manage_orders ?? false,
can_manage_pickup: inserted.can_manage_pickup, can_manage_pickup: inserted.can_manage_pickup ?? false,
can_manage_messages: inserted.can_manage_messages, can_manage_messages: inserted.can_manage_messages ?? false,
can_manage_refunds: inserted.can_manage_refunds, can_manage_refunds: inserted.can_manage_refunds ?? false,
can_manage_users: inserted.can_manage_users, can_manage_users: inserted.can_manage_users ?? false,
can_manage_water_log: inserted.can_manage_water_log, can_manage_water_log: inserted.can_manage_water_log ?? false,
can_manage_reports: inserted.can_manage_reports, can_manage_reports: inserted.can_manage_reports ?? false,
active: inserted.active, active: inserted.active ?? true,
must_change_password: inserted.must_change_password ?? true, must_change_password: inserted.must_change_password ?? true,
created_at: inserted.created_at, created_at: inserted.created_at ?? new Date().toISOString(),
last_login: null, last_login: null,
}, },
error: null, error: null,
@@ -564,8 +462,7 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly // DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
if (rcAuthUid === DEV_FORCE_UID) { if (rcAuthUid === DEV_FORCE_UID) {
const service = getServiceClient(); const { data, error } = await svcApi
const { data, error } = await service
.from("admin_users") .from("admin_users")
.update({ .update({
role: input.role ?? undefined, role: input.role ?? undefined,
@@ -586,8 +483,8 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us
.eq("id", input.id) .eq("id", input.id)
.select() .select()
.single(); .single();
if (error) return { user: null, error: error.message }; if (error || !data) return { user: null, error: error?.message ?? "Update returned no row" };
return { user: mapUserRow(data), error: null }; return { user: mapUserRow(data as Record<string, unknown>), error: null };
} }
const result = await callRpcWithAuth<AdminUserRow[]>("update_admin_user", { const result = await callRpcWithAuth<AdminUserRow[]>("update_admin_user", {
@@ -613,20 +510,19 @@ export async function deleteAdminUser(id: string): Promise<{ success: boolean; e
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly // DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
if (rcAuthUid === DEV_FORCE_UID) { if (rcAuthUid === DEV_FORCE_UID) {
const service = getServiceClient();
// Get user_id first // Get user_id first
const { data: adminRow, error: fetchError } = await service const { data: adminRow, error: fetchError } = await svcApi
.from("admin_users") .from("admin_users")
.select("user_id") .select("user_id")
.eq("id", id) .eq("id", id)
.single(); .single();
if (fetchError) return { success: false, error: fetchError.message }; if (fetchError) return { success: false, error: fetchError.message };
// Delete from admin_users // Delete from admin_users
const { error: deleteError } = await service.from("admin_users").delete().eq("id", id); const { error: deleteError } = await svcApi.from("admin_users").delete().eq("id", id);
if (deleteError) return { success: false, error: deleteError.message }; if (deleteError) return { success: false, error: deleteError.message };
// Delete auth user // Delete auth user via better-auth admin
if (adminRow?.user_id) { if (adminRow?.user_id) {
await service.auth.admin.deleteUser(adminRow.user_id); await authAdmin.removeUser(adminRow.user_id);
} }
return { success: true, error: null }; return { success: true, error: null };
} }
@@ -643,30 +539,24 @@ export async function setMustChangePassword(userId: string): Promise<{ success:
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
if (rcAuthUid === DEV_FORCE_UID || process.env.NODE_ENV !== "production") { if (rcAuthUid === DEV_FORCE_UID || process.env.NODE_ENV !== "production") {
const service = getServiceClient(); const { error } = await svcApi.from("admin_users").update({ must_change_password: true }).eq("id", userId);
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
return { success: !error, error: error?.message ?? null }; return { success: !error, error: error?.message ?? null };
} }
// Production path — use service role via direct update // Production path — use service role via direct update
const service = getServiceClient(); const { error } = await svcApi.from("admin_users").update({ must_change_password: true }).eq("id", userId);
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
return { success: !error, error: error?.message ?? null }; return { success: !error, error: error?.message ?? null };
} }
export async function sendPasswordResetEmail(email: string): Promise<{ success: boolean; error: string | null }> { export async function sendPasswordResetEmail(email: string): Promise<{ success: boolean; error: string | null }> {
const { error } = await publicSupabase.auth.resetPasswordForEmail(email, { const { error } = await authAdmin.requestPasswordReset({
email,
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`, redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`,
}); });
return { success: !error, error: error?.message ?? null }; return { success: !error, error: error?.message ?? null };
} }
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> { export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
if (useMockData) { const { data, error } = await publicApi.from("brands").select("id, name").order("name");
const brands = mockBrands.map(b => ({ id: b.id, name: b.name }));
return { brands, error: null };
}
const { data, error } = await publicSupabase.from("brands").select("id, name").order("name");
return { brands: data ?? [], error: error?.message ?? null }; return { brands: data ?? [], error: error?.message ?? null };
} }
+3 -3
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { supabase } from "@/lib/supabase"; import { api } from "@/lib/api";
export type AIAuthConfig = { export type AIAuthConfig = {
provider: string; provider: string;
@@ -18,7 +18,7 @@ export async function getAIPreferences(brandId: string): Promise<{
model?: string; model?: string;
max_tokens?: number; max_tokens?: number;
} | null> { } | null> {
const { data, error } = await supabase const { data, error } = await api
.from("brand_ai_settings") .from("brand_ai_settings")
.select("api_key, organization_id, base_url, model, max_tokens") .select("api_key, organization_id, base_url, model, max_tokens")
.eq("brand_id", brandId) .eq("brand_id", brandId)
@@ -32,7 +32,7 @@ export async function saveAIPreferences(
brandId: string, brandId: string,
config: AIAuthConfig config: AIAuthConfig
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const { error } = await supabase const { error } = await api
.from("brand_ai_settings") .from("brand_ai_settings")
.upsert({ .upsert({
brand_id: brandId, brand_id: brandId,
+2 -2
View File
@@ -3,8 +3,8 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -27,8 +27,8 @@ type AuditResult =
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function. * Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
*/ */
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> { export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
+2 -2
View File
@@ -39,8 +39,8 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing"; import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
export type BillingSubscriptionStatus = export type BillingSubscriptionStatus =
| "active" | "active"
+2 -2
View File
@@ -32,8 +32,8 @@ export async function createRetailStripeCheckoutSession(
})); }));
// Get brand name for Stripe metadata // Get brand name for Stripe metadata
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
let brandName = "Route Commerce"; let brandName = "Route Commerce";
try { try {
const brandRes = await fetch( const brandRes = await fetch(
+4 -4
View File
@@ -43,8 +43,8 @@ export async function createStripeCheckoutSession(
const priceId = getPriceId(priceKey); const priceId = getPriceId(priceKey);
if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` }; if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Get brand's Stripe customer ID // Get brand's Stripe customer ID
const custRes = await fetch( const custRes = await fetch(
@@ -123,8 +123,8 @@ export async function cancelAddonSubscription(
const stripeKey = process.env.STRIPE_SECRET_KEY; const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) return { success: false, error: "Stripe not configured" }; if (!stripeKey) return { success: false, error: "Stripe not configured" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Get active subscription for this brand // Get active subscription for this brand
const subRes = await fetch( const subRes = await fetch(
+12 -12
View File
@@ -13,8 +13,8 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
const stripeKey = process.env.STRIPE_SECRET_KEY; const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) return { success: false, error: "Stripe not configured" }; if (!stripeKey) return { success: false, error: "Stripe not configured" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Get stripe_customer_id from brands table // Get stripe_customer_id from brands table
const custRes = await fetch( const custRes = await fetch(
@@ -49,8 +49,8 @@ export async function updateBrandPlanTier(brandId: string, planTier: string): Pr
return { success: false, error: "Invalid plan tier" }; return { success: false, error: "Invalid plan tier" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`, `${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`,
@@ -72,8 +72,8 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_brand_stripe_customer_id`, `${supabaseUrl}/rest/v1/rpc/update_brand_stripe_customer_id`,
@@ -89,8 +89,8 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
} }
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> { export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`, `${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`,
@@ -108,8 +108,8 @@ export async function getBrandPlanInfo(brandId: string): Promise<{ success: bool
} }
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> { export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_features`, `${supabaseUrl}/rest/v1/rpc/get_brand_features`,
@@ -128,8 +128,8 @@ export async function getEnabledAddons(brandId: string): Promise<Record<string,
} }
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> { export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, `${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
+53 -59
View File
@@ -2,6 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
import { uploadFile, BUCKETS, publicUrl, storageKeys } from "@/lib/storage";
export type UploadLogoResult = export type UploadLogoResult =
| { success: true; logoUrl: string } | { 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 ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
const path = isDark ? `logo-dark.${ext}` : `logo.${ext}`; const fileName = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
const storagePath = `brand-logos/${brandId}/${path}`; const key = storageKeys.brandLogo(brandId, fileName);
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let url: string;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; try {
const res = await uploadFile({
const uploadRes = await fetch( bucket: BUCKETS.BRAND_LOGOS,
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, key,
{
method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
body: buffer, body: buffer,
} contentType: file.type,
); });
url = res.url;
if (!uploadRes.ok) { } catch (e) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; 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_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Save URL to brand_settings via RPC
const saveRes = await fetch( const saveRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, `${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
{ {
@@ -62,7 +60,7 @@ export async function uploadBrandLogo(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
p_brand_id: brandId, 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: false, error: "Upload succeeded but failed to save URL" };
} }
return { success: true, logoUrl: publicUrl }; return { success: true, logoUrl: url };
} }
export async function uploadOlatheSweetLogo( 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 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 arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let url: string;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; try {
const res = await uploadFile({
const uploadRes = await fetch( bucket: BUCKETS.BRAND_LOGOS,
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, key,
{
method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
body: buffer, body: buffer,
} contentType: file.type,
); });
url = res.url;
if (!uploadRes.ok) { } catch (e) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; 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_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const saveRes = await fetch( const saveRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, `${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
@@ -126,7 +122,7 @@ export async function uploadOlatheSweetLogo(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
p_brand_id: brandId, 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: false, error: "Upload succeeded but failed to save URL" };
} }
return { success: true, logoUrl: publicUrl }; return { success: true, logoUrl: url };
} }
export async function uploadOlatheSweetLogoDark( 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 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 arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let url: string;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; try {
const res = await uploadFile({
const uploadRes = await fetch( bucket: BUCKETS.BRAND_LOGOS,
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, key,
{
method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
body: buffer, body: buffer,
} contentType: file.type,
); });
url = res.url;
if (!uploadRes.ok) { } catch (e) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; 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_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const saveRes = await fetch( const saveRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, `${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
@@ -190,7 +184,7 @@ export async function uploadOlatheSweetLogoDark(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
p_brand_id: brandId, 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: false, error: "Upload succeeded but failed to save URL" };
} }
return { success: true, logoUrl: publicUrl }; return { success: true, logoUrl: url };
} }
export type BrandSettings = { export type BrandSettings = {
@@ -252,8 +246,8 @@ export type SaveBrandSettingsResult =
| { success: false; error: string }; | { success: false; error: string };
export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> { export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings`, `${supabaseUrl}/rest/v1/rpc/get_brand_settings`,
@@ -271,8 +265,8 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
// Public version for storefront pages — uses slug, no auth required // Public version for storefront pages — uses slug, no auth required
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> { export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`, `${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
@@ -337,8 +331,8 @@ export async function saveBrandSettings(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, `${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
+8 -8
View File
@@ -64,8 +64,8 @@ export async function createOrder(
brandId?: string, brandId?: string,
shippingAddress?: ShippingAddress shippingAddress?: ShippingAddress
): Promise<CheckoutResult> { ): Promise<CheckoutResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// ── Calculate tax if brand collects tax ───────────────────────────────── // ── Calculate tax if brand collects tax ─────────────────────────────────
let taxAmount = 0; let taxAmount = 0;
@@ -150,8 +150,8 @@ export async function createOrder(
// ── Cart Persistence ────────────────────────────────────────────────────────── // ── Cart Persistence ──────────────────────────────────────────────────────────
export async function getServerCart(userId: string): Promise<CartItem[]> { export async function getServerCart(userId: string): Promise<CartItem[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
try { try {
const response = await fetch( const response = await fetch(
@@ -177,8 +177,8 @@ export async function mergeLocalCart(
): Promise<{ merged: CartItem[] }> { ): Promise<{ merged: CartItem[] }> {
if (!localCart || localCart.length === 0) return { merged: [] }; if (!localCart || localCart.length === 0) return { merged: [] };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// Fetch server cart // Fetch server cart
let serverCart: CartItem[] = []; let serverCart: CartItem[] = [];
@@ -243,8 +243,8 @@ export async function mergeLocalCart(
} }
export async function clearServerCart(userId: string): Promise<void> { export async function clearServerCart(userId: string): Promise<void> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
try { try {
await fetch( await fetch(
+8 -8
View File
@@ -59,8 +59,8 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null; const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`, `${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`,
@@ -97,8 +97,8 @@ export async function upsertCampaign(params: {
return { success: false, error: "Not authorized to operate on this brand's campaigns" }; return { success: false, error: "Not authorized to operate on this brand's campaigns" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_communication_campaign`, `${supabaseUrl}/rest/v1/rpc/upsert_communication_campaign`,
@@ -139,8 +139,8 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
return { success: false, error: "Not authorized to delete this brand's campaigns" }; return { success: false, error: "Not authorized to delete this brand's campaigns" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_communication_campaign`, `${supabaseUrl}/rest/v1/rpc/delete_communication_campaign`,
@@ -159,8 +159,8 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return null; if (!adminUser) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_campaign_by_id`, `${supabaseUrl}/rest/v1/rpc/get_communication_campaign_by_id`,
+12 -12
View File
@@ -109,8 +109,8 @@ export async function getContacts(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_contacts`, `${supabaseUrl}/rest/v1/rpc/get_communication_contacts`,
@@ -172,8 +172,8 @@ export async function upsertContact(contact: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_communication_contact`, `${supabaseUrl}/rest/v1/rpc/upsert_communication_contact`,
@@ -228,8 +228,8 @@ export async function importContactsBatch(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/import_communication_contacts_batch`, `${supabaseUrl}/rest/v1/rpc/import_communication_contacts_batch`,
@@ -271,8 +271,8 @@ export async function optOutContact(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/opt_out_contact`, `${supabaseUrl}/rest/v1/rpc/opt_out_contact`,
@@ -302,8 +302,8 @@ export async function deleteContact(id: string, brandId?: string): Promise<{ suc
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_communication_contact`, `${supabaseUrl}/rest/v1/rpc/delete_communication_contact`,
@@ -353,8 +353,8 @@ export async function exportContacts(params: {
if (!effectiveBrandId) return { success: false, error: "No brand context" }; if (!effectiveBrandId) return { success: false, error: "No brand context" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const allContacts: Contact[] = []; const allContacts: Contact[] = [];
let offset = 0; let offset = 0;
+30 -66
View File
@@ -2,9 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
import { uploadFile, listFiles, BUCKETS, publicUrl, storageKeys } from "@/lib/storage";
// Contact imports bucket
const CONTACTS_BUCKET_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
export type UploadContactsResult = export type UploadContactsResult =
| { success: true; fileId: string; fileUrl: string; recordCount: number } | { success: true; fileId: string; fileUrl: string; recordCount: number }
@@ -17,57 +15,41 @@ export async function uploadContactsToBucket(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
// Validate file type
if (!file.name.endsWith(".csv")) { if (!file.name.endsWith(".csv")) {
return { success: false, error: "Only CSV files are supported" }; 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 const maxSize = 50 * 1024 * 1024; // 50MB max
if (file.size > maxSize) { if (file.size > maxSize) {
return { success: false, error: "File too large. Max 50MB for large imports." }; 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 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 arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const uploadRes = await fetch( let url: string;
`${supabaseUrl}/storage/v1/object/${CONTACTS_BUCKET_ID}/${path}`, try {
{ const res = await uploadFile({
method: "PUT", bucket: BUCKETS.CONTACTS_IMPORTS,
headers: { key,
...svcHeaders(supabaseKey),
"Authorization": `Bearer ${supabaseKey}`,
"Content-Type": "text/csv",
"x-upsert": "false",
},
body: buffer, body: buffer,
} contentType: "text/csv",
); });
url = res.url;
if (!uploadRes.ok) { } catch (e) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; return { success: false, error: `Upload failed: ${(e as Error).message}` };
} }
const fileUrl = `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${path}`; const fileId = key.split("/").slice(0, 2).join("/"); // brandId/timestamp
const fileId = `${brandId}/${timestamp}`;
// Get rough row count from file size (approx 200 bytes per row)
const estimatedRows = Math.floor(file.size / 200); const estimatedRows = Math.floor(file.size / 200);
return { return {
success: true, success: true,
fileId, fileId,
fileUrl, fileUrl: url,
recordCount: estimatedRows, recordCount: estimatedRows,
}; };
} }
@@ -84,10 +66,9 @@ export async function processBucketImport(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Call RPC to process the file from bucket
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`, `${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`,
{ {
@@ -122,37 +103,20 @@ export async function listImportHistory(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const files = await listFiles(BUCKETS.CONTACTS_IMPORTS, `${brandId}/`);
return {
// List files in the imports folder for this brand success: true,
const response = await fetch( imports: files.slice(0, limit).map((f) => ({
`${supabaseUrl}/storage/v1/object/list/${CONTACTS_BUCKET_ID}`, filename: f.key.split("/").pop() ?? "",
{ size: f.size,
method: "POST", createdAt: f.lastModified.toISOString(),
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, url: publicUrl(BUCKETS.CONTACTS_IMPORTS, f.key),
body: JSON.stringify({ })),
prefix: `imports/${brandId}/`, };
limit: limit, } catch (e) {
sortBy: { column: "created_at", order: "desc" }, return { success: false, error: (e as Error).message };
}),
}
);
if (!response.ok) {
return { success: false, error: "Failed to list imports" };
} }
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 = { export type ImportHistoryItem = {
@@ -160,4 +124,4 @@ export type ImportHistoryItem = {
size: number; size: number;
createdAt: string; createdAt: string;
url: string; url: string;
}; };
+6 -6
View File
@@ -33,8 +33,8 @@ export async function getCommunicationSegments(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_segments`, `${supabaseUrl}/rest/v1/rpc/get_communication_segments`,
@@ -64,8 +64,8 @@ export async function upsertSegment(params: {
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`, `${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`,
@@ -99,8 +99,8 @@ export async function deleteSegment(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_communication_segment`, `${supabaseUrl}/rest/v1/rpc/delete_communication_segment`,
+6 -6
View File
@@ -21,8 +21,8 @@ export async function previewCampaignAudience(
return null; return null;
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`, `${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`,
@@ -86,8 +86,8 @@ export async function getMessageLogs(params: {
return { success: false, error: "Not authorized to view these logs" }; return { success: false, error: "Not authorized to view these logs" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_message_logs`, `${supabaseUrl}/rest/v1/rpc/get_message_logs`,
@@ -128,8 +128,8 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis
return { success: false, error: "Not authorized to send this brand's campaigns" }; return { success: false, error: "Not authorized to send this brand's campaigns" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/send_campaign`, `${supabaseUrl}/rest/v1/rpc/send_campaign`,
+4 -4
View File
@@ -24,8 +24,8 @@ export type UpsertSettingsResult = {
}; };
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> { export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_settings`, `${supabaseUrl}/rest/v1/rpc/get_communication_settings`,
@@ -57,8 +57,8 @@ export async function upsertCommunicationSettings(params: {
return { success: false, error: "Not authorized to modify this brand's communication settings" }; return { success: false, error: "Not authorized to modify this brand's communication settings" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_communication_settings`, `${supabaseUrl}/rest/v1/rpc/upsert_communication_settings`,
+2 -2
View File
@@ -22,8 +22,8 @@ export async function sendStopBlast(params: {
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/send_stop_blast`, `${supabaseUrl}/rest/v1/rpc/send_stop_blast`,
+6 -6
View File
@@ -40,8 +40,8 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
return { success: true, templates: [] }; return { success: true, templates: [] };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_templates`, `${supabaseUrl}/rest/v1/rpc/get_communication_templates`,
@@ -70,8 +70,8 @@ export async function upsertTemplate(params: {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_communication_template`, `${supabaseUrl}/rest/v1/rpc/upsert_communication_template`,
@@ -104,8 +104,8 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return null; if (!adminUser) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_templates`, `${supabaseUrl}/rest/v1/rpc/get_communication_templates`,
+2 -2
View File
@@ -3,8 +3,8 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -3,8 +3,8 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -3,8 +3,8 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
+4 -4
View File
@@ -53,8 +53,8 @@ export async function getHarvestReachCampaigns(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`, `${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`,
@@ -82,8 +82,8 @@ export async function getCampaignAnalytics(
if (!adminUser) return []; if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return []; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_campaign_analytics`, `${supabaseUrl}/rest/v1/rpc/get_campaign_analytics`,
+2 -2
View File
@@ -17,8 +17,8 @@ export async function getProductsForSegmentPicker(
if (!adminUser) return []; if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return []; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_products_for_segment_picker`, `${supabaseUrl}/rest/v1/rpc/get_products_for_segment_picker`,
+8 -8
View File
@@ -75,8 +75,8 @@ export async function getHarvestReachSegments(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_segments`, `${supabaseUrl}/rest/v1/rpc/get_communication_segments`,
@@ -110,8 +110,8 @@ export async function upsertHarvestReachSegment(params: {
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`, `${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`,
@@ -149,8 +149,8 @@ export async function deleteHarvestReachSegment(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_communication_segment`, `${supabaseUrl}/rest/v1/rpc/delete_communication_segment`,
@@ -180,8 +180,8 @@ export async function previewSegmentWithCustomers(
return null; return null;
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`, `${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`,
+2 -2
View File
@@ -23,8 +23,8 @@ export async function getStopsForSegmentPicker(
if (!adminUser) return []; if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return []; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_stops_for_segment_picker`, `${supabaseUrl}/rest/v1/rpc/get_stops_for_segment_picker`,
+2 -2
View File
@@ -25,8 +25,8 @@ export async function importOrdersBatch(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const results = { imported: 0, errors: [] as { row: number; error: string }[] }; const results = { imported: 0, errors: [] as { row: number; error: string }[] };
+2 -2
View File
@@ -26,8 +26,8 @@ export async function importProductsBatch(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`, `${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`,
+10 -10
View File
@@ -33,8 +33,8 @@ const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1";
// ── Get AI provider settings ───────────────────────────────────────────────────── // ── Get AI provider settings ─────────────────────────────────────────────────────
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> { export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_ai_provider_settings`, `${supabaseUrl}/rest/v1/rpc/get_ai_provider_settings`,
@@ -75,8 +75,8 @@ export async function setAIProviderSettings(
const current = await getAIProviderSettings(brandId); const current = await getAIProviderSettings(brandId);
const merged = { ...current, ...settings }; const merged = { ...current, ...settings };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY;
const payload = { const payload = {
provider: merged.provider, provider: merged.provider,
@@ -206,8 +206,8 @@ export async function getAIClient(brandId: string): Promise<{
// ── Custom integrations ───────────────────────────────────────────────────────── // ── Custom integrations ─────────────────────────────────────────────────────────
export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> { export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_custom_integrations`, `${supabaseUrl}/rest/v1/rpc/get_custom_integrations`,
@@ -234,8 +234,8 @@ export async function upsertCustomIntegration(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" }; if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_custom_integration`, `${supabaseUrl}/rest/v1/rpc/upsert_custom_integration`,
@@ -263,8 +263,8 @@ export async function deleteCustomIntegration(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" }; if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_custom_integration`, `${supabaseUrl}/rest/v1/rpc/delete_custom_integration`,
+8 -8
View File
@@ -24,8 +24,8 @@ export type SaveCredentialsResult =
// ── Resend Credentials ───────────────────────────────────────────────────────── // ── Resend Credentials ─────────────────────────────────────────────────────────
export async function getResendCredentials(brandId: string): Promise<ResendCredentials> { export async function getResendCredentials(brandId: string): Promise<ResendCredentials> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_resend_credentials`, `${supabaseUrl}/rest/v1/rpc/get_resend_credentials`,
@@ -66,8 +66,8 @@ export async function saveResendCredentials(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Get current credentials to merge // Get current credentials to merge
const current = await getResendCredentials(brandId); const current = await getResendCredentials(brandId);
@@ -99,8 +99,8 @@ export async function saveResendCredentials(
// ── Twilio Credentials ───────────────────────────────────────────────────────── // ── Twilio Credentials ─────────────────────────────────────────────────────────
export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> { export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_twilio_credentials`, `${supabaseUrl}/rest/v1/rpc/get_twilio_credentials`,
@@ -141,8 +141,8 @@ export async function saveTwilioCredentials(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Get current credentials to merge // Get current credentials to merge
const current = await getTwilioCredentials(brandId); const current = await getTwilioCredentials(brandId);
+16 -41
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { cookies } from "next/headers"; import { headers } from "next/headers";
import { createServerClient } from "@supabase/ssr"; import { auth } from "@/lib/auth";
export type LoginWithPasswordResult = export type LoginWithPasswordResult =
| { success: true; redirect: true } | { success: true; redirect: true }
@@ -11,46 +11,21 @@ export async function loginWithPassword(
email: string, email: string,
password: string password: string
): Promise<LoginWithPasswordResult> { ): Promise<LoginWithPasswordResult> {
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; if (!result?.user) {
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; return { success: false, error: "Invalid credentials" };
}
if (!supabaseUrl || !supabaseAnonKey) { return { success: true, redirect: true };
return { success: false, error: "Server misconfiguration." }; } 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 };
} }
+4 -64
View File
@@ -2,7 +2,6 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
import { mockOrders, mockStops } from "@/lib/mock-data";
type AdminOrder = { type AdminOrder = {
id: string; id: string;
@@ -106,51 +105,12 @@ type AdminOrderDetail = {
}>; }>;
}; };
// Mock orders for UI review
const mockAdminOrders: AdminOrder[] = mockOrders.map((o: any) => ({
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: null,
created_at: o.created_at,
payment_processor: o.payment_processor,
stops: o.stops,
order_items: o.order_items,
}));
const mockAdminStops: AdminStop[] = mockStops.map((s: any) => ({
id: s.id,
city: s.city,
state: s.state,
date: s.date,
location: s.location,
brand_id: s.brand_id,
}));
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export async function getAdminOrders(): Promise<AdminOrdersResponse> { export async function getAdminOrders(): Promise<AdminOrdersResponse> {
// Return mock data in mock mode
if (useMockData) {
return {
success: true,
orders: mockAdminOrders,
stops: mockAdminStops,
error: null,
};
}
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? null; const brandId = adminUser?.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_admin_orders`, `${supabaseUrl}/rest/v1/rpc/get_admin_orders`,
@@ -193,31 +153,11 @@ export async function getAdminPickedUpOrders(): Promise<AdminOrder[]> {
} }
export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDetail | null> { export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDetail | null> {
// Return mock order detail in mock mode
if (useMockData) {
const order = mockAdminOrders.find((o) => o.id === orderId);
if (!order) return null;
return {
...order,
discount_amount: null,
tax_amount: order.subtotal * 0.08,
tax_rate: 0.08,
tax_location: "Colorado",
discount_reason: null,
internal_notes: null,
payment_status: "paid",
payment_transaction_id: `txn_mock_${orderId}`,
refunded_amount: 0,
refund_reason: null,
refunds: [],
};
}
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? null; const brandId = adminUser?.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_admin_order_detail`, `${supabaseUrl}/rest/v1/rpc/get_admin_order_detail`,
+2 -2
View File
@@ -53,8 +53,8 @@ export async function createAdminOrder(
return { success: false, error: "At least one item is required" }; return { success: false, error: "At least one item is required" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Build items for RPC (match checkout shape) // Build items for RPC (match checkout shape)
const rpcItems = input.items.map((i) => ({ const rpcItems = input.items.map((i) => ({
+2 -2
View File
@@ -22,8 +22,8 @@ export async function createRefund(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const res = await fetch(`${supabaseUrl}/rest/v1/refunds`, { const res = await fetch(`${supabaseUrl}/rest/v1/refunds`, {
method: "POST", method: "POST",
+6 -6
View File
@@ -36,8 +36,8 @@ export async function updateOrder(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const patchData: Record<string, unknown> = {}; const patchData: Record<string, unknown> = {};
if (data.customer_name !== undefined) patchData.customer_name = data.customer_name; if (data.customer_name !== undefined) patchData.customer_name = data.customer_name;
@@ -85,8 +85,8 @@ export async function updateOrderItem(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const patchData: Record<string, unknown> = {}; const patchData: Record<string, unknown> = {};
if (data.quantity !== undefined) patchData.quantity = data.quantity; if (data.quantity !== undefined) patchData.quantity = data.quantity;
@@ -111,8 +111,8 @@ export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const res = await fetch(`${supabaseUrl}/rest/v1/order_items?id=eq.${itemId}`, { const res = await fetch(`${supabaseUrl}/rest/v1/order_items?id=eq.${itemId}`, {
method: "DELETE", method: "DELETE",
+4 -4
View File
@@ -25,8 +25,8 @@ export type GetPaymentSettingsResult =
| { success: false; error: string }; | { success: false; error: string };
export async function getPaymentSettings(brandId: string): Promise<GetPaymentSettingsResult> { export async function getPaymentSettings(brandId: string): Promise<GetPaymentSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_payment_settings`, `${supabaseUrl}/rest/v1/rpc/get_payment_settings`,
@@ -72,8 +72,8 @@ export async function savePaymentSettings(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`, `${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`,
+10 -10
View File
@@ -28,9 +28,9 @@ export async function markPickupComplete(
// brand_admin: verify the order belongs to their brand // brand_admin: verify the order belongs to their brand
if (adminUser.role === "brand_admin" && adminUser.brand_id) { if (adminUser.role === "brand_admin" && adminUser.brand_id) {
const brandRes = await fetch( const brandRes = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=stop_id,brand_id`, `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/orders?id=eq.${orderId}&select=stop_id,brand_id`,
{ {
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!), headers: svcHeaders(process.env.NEXT_PUBLIC_API_ANON_KEY!),
} }
); );
@@ -52,9 +52,9 @@ export async function markPickupComplete(
if (!order.brand_id && order.stop_id) { if (!order.brand_id && order.stop_id) {
const stopRes = await fetch( const stopRes = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?id=eq.${order.stop_id}&select=brand_id`, `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/stops?id=eq.${order.stop_id}&select=brand_id`,
{ {
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!), headers: svcHeaders(process.env.NEXT_PUBLIC_API_ANON_KEY!),
} }
); );
@@ -69,11 +69,11 @@ export async function markPickupComplete(
// PATCH the order // PATCH the order
const patchRes = await fetch( const patchRes = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}`, `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/orders?id=eq.${orderId}`,
{ {
method: "PATCH", method: "PATCH",
headers: { headers: {
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!), ...svcHeaders(process.env.NEXT_PUBLIC_API_ANON_KEY!),
"Content-Type": "application/json", "Content-Type": "application/json",
Prefer: "return=representation", Prefer: "return=representation",
}, },
@@ -111,9 +111,9 @@ export async function markPickupComplete(
// Emit pickup_completed event // Emit pickup_completed event
// Need brand_id — get it from the order we just patched // Need brand_id — get it from the order we just patched
const orderRes = await fetch( const orderRes = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=brand_id`, `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/orders?id=eq.${orderId}&select=brand_id`,
{ {
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!), headers: svcHeaders(process.env.NEXT_PUBLIC_API_ANON_KEY!),
} }
); );
if (orderRes.ok) { if (orderRes.ok) {
@@ -121,11 +121,11 @@ export async function markPickupComplete(
const orderBrandId = Array.isArray(orderData) && orderData[0]?.brand_id; const orderBrandId = Array.isArray(orderData) && orderData[0]?.brand_id;
if (orderBrandId) { if (orderBrandId) {
await fetch( await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/record_pickup_completed_event`, `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/record_pickup_completed_event`,
{ {
method: "POST", method: "POST",
headers: { headers: {
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!), ...svcHeaders(process.env.NEXT_PUBLIC_API_ANON_KEY!),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
+2 -2
View File
@@ -3,8 +3,8 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
+4 -4
View File
@@ -18,8 +18,8 @@ export async function deleteProduct(
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null; const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_product`, `${supabaseUrl}/rest/v1/rpc/delete_product`,
@@ -63,8 +63,8 @@ async function logAuditEvent(event: {
new_data: Record<string, unknown>; new_data: Record<string, unknown>;
brand_id: string | null; brand_id: string | null;
}) { }) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
await fetch( await fetch(
`${supabaseUrl}/rest/v1/rpc.log_audit_event`, `${supabaseUrl}/rest/v1/rpc.log_audit_event`,
+2 -24
View File
@@ -1,11 +1,8 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getMockTableData } from "@/lib/mock-data";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type CreateProductResult = export type CreateProductResult =
| { success: true; id: string } | { success: true; id: string }
| { success: false; error: string }; | { success: false; error: string };
@@ -31,27 +28,8 @@ export async function createProduct(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
if (useMockData) { const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const mockProducts = getMockTableData("products") as any[]; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const newId = `mock-prod-${Date.now()}`;
const newProduct = {
id: newId,
name: data.name,
description: data.description,
price: data.price,
type: data.type,
is_active: data.active,
image_url: data.image_url ?? null,
is_taxable: data.is_taxable,
pickup_type: data.pickup_type ?? "scheduled_stop",
brand_id: brandId,
};
mockProducts.push(newProduct);
return { success: true, id: newId };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`, { const res = await fetch(`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`, {
method: "POST", method: "POST",
+2 -9
View File
@@ -1,11 +1,8 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getMockTableData } from "@/lib/mock-data";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type UpdateProductResult = export type UpdateProductResult =
| { success: true } | { success: true }
| { success: false; error: string }; | { success: false; error: string };
@@ -32,12 +29,8 @@ export async function updateProduct(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
if (useMockData) { const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
return { success: true }; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(`${supabaseUrl}/rest/v1/products?id=eq.${productId}`, { const res = await fetch(`${supabaseUrl}/rest/v1/products?id=eq.${productId}`, {
method: "PATCH", method: "PATCH",
+24 -27
View File
@@ -2,9 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
import { uploadFile, BUCKETS, storageKeys } from "@/lib/storage";
// Product images bucket - UUID from Supabase
const PRODUCT_IMAGES_BUCKET_ID = "80aa01da-ab4b-44f8-b6e7-700552457e18";
export type UploadProductImageResult = export type UploadProductImageResult =
| { success: true; imageUrl: string } | { success: true; imageUrl: string }
@@ -25,42 +23,41 @@ export async function uploadProductImage(
return { success: false, error: "File too large. Max 5MB." }; return { success: false, error: "File too large. Max 5MB." };
} }
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1]; const rawExt = file.type.split("/")[1];
const path = `products/${productId}/${crypto.randomUUID()}.${ext}`; const ext = rawExt === "jpeg" ? "jpg" : rawExt;
const targetProductId = productId === "__NEW__" ? "new" : productId;
const key = storageKeys.productImage(targetProductId, ext);
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let url: string;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; try {
const res = await uploadFile({
const uploadRes = await fetch( bucket: BUCKETS.PRODUCT_IMAGES,
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`, key,
{
method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Authorization": `Bearer ${supabaseKey}`, "Content-Type": `image/${ext}`, "x-upsert": "true" },
body: buffer, body: buffer,
} contentType: file.type,
); });
url = res.url;
if (!uploadRes.ok) { } catch (e) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; 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 is "__NEW__", we just upload and return the URL (caller handles saving it)
if (productId === "__NEW__") { 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_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const patchRes = await fetch( const patchRes = await fetch(
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`, `${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
{ {
method: "PATCH", method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, 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: false, error: "Upload succeeded but failed to save image URL" };
} }
return { success: true, imageUrl: publicUrl }; return { success: true, imageUrl: url };
} }
export async function deleteProductImage( export async function deleteProductImage(
@@ -77,8 +74,8 @@ export async function deleteProductImage(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const patchRes = await fetch( const patchRes = await fetch(
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`, `${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
@@ -94,4 +91,4 @@ export async function deleteProductImage(
} }
return { success: true }; return { success: true };
} }
+2 -2
View File
@@ -3,8 +3,8 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
export type DateRange = { start: string; end: string }; export type DateRange = { start: string; end: string };
+1 -1
View File
@@ -3,7 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; const SUPABASE_URL = process.env.NEXT_PUBLIC_API_URL!;
const SUPABASE_PAT = process.env.SUPABASE_PAT!; const SUPABASE_PAT = process.env.SUPABASE_PAT!;
async function adminFetch(endpoint: string, options?: RequestInit) { async function adminFetch(endpoint: string, options?: RequestInit) {
+2 -2
View File
@@ -23,8 +23,8 @@ export async function toggleBrandFeature(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`, `${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
+4 -4
View File
@@ -16,8 +16,8 @@ export async function updateShippingStatus(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_shipping_order`, `${supabaseUrl}/rest/v1/rpc/update_shipping_order`,
@@ -70,8 +70,8 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_shipping_orders`, `${supabaseUrl}/rest/v1/rpc/get_shipping_orders`,
+6 -6
View File
@@ -58,8 +58,8 @@ async function getFedExTokenForCreate(
brandId: string | null, brandId: string | null,
adminUserId: string | null adminUserId: string | null
): Promise<{ accessToken: string } | { error: string }> { ): Promise<{ accessToken: string } | { error: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// Try to get from shipping_settings // Try to get from shipping_settings
const settingsRes = await fetch( const settingsRes = await fetch(
@@ -88,8 +88,8 @@ async function getFedExTokenForCreate(
} }
async function isShipmentPerishable(orderId: string): Promise<boolean> { async function isShipmentPerishable(orderId: string): Promise<boolean> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`, `${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`,
@@ -128,8 +128,8 @@ export async function createFedExShipment(
return { success: false, error: "Not authorized to create shipments" }; return { success: false, error: "Not authorized to create shipments" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// Fetch order // Fetch order
const orderRes = await fetch( const orderRes = await fetch(
+4 -4
View File
@@ -95,8 +95,8 @@ async function isOrderPerishable(
orderId: string, orderId: string,
brandId: string | null brandId: string | null
): Promise<boolean> { ): Promise<boolean> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`, `${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`,
@@ -151,8 +151,8 @@ export async function getFedExRates(
return { success: false, error: "Not authorized to view shipping rates" }; return { success: false, error: "Not authorized to view shipping rates" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// Fetch order + brand + address // Fetch order + brand + address
const orderRes = await fetch( const orderRes = await fetch(
+4 -4
View File
@@ -77,8 +77,8 @@ async function getFedExToken(apiKey: string, apiSecret: string, useProduction: b
// ── Get Settings ───────────────────────────────────────────────────────────── // ── Get Settings ─────────────────────────────────────────────────────────────
export async function getShippingSettings(brandId: string): Promise<GetShippingSettingsResult> { export async function getShippingSettings(brandId: string): Promise<GetShippingSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(brandId)}&limit=1`, `${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(brandId)}&limit=1`,
@@ -115,8 +115,8 @@ export async function saveShippingSettings(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// Get existing settings to get ID for update // Get existing settings to get ID for update
const existing = await fetch( const existing = await fetch(
+4 -4
View File
@@ -110,8 +110,8 @@ export async function syncInventoryToSquare(
const errors: string[] = []; const errors: string[] = [];
const synced = 0; const synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Build product name → quantity map // Build product name → quantity map
const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity])); const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity]));
@@ -177,8 +177,8 @@ export async function syncInventoryFromSquare(brandId: string): Promise<SyncResu
const errors: string[] = []; const errors: string[] = [];
const synced = 0; const synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
try { try {
// Fetch Square inventory counts for the location // Fetch Square inventory counts for the location
+2 -2
View File
@@ -85,8 +85,8 @@ export async function syncOrdersFromSquare(brandId: string): Promise<SyncResult>
const errors: string[] = []; const errors: string[] = [];
let synced = 0; let synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// Determine sync start time — last sync or 30 days ago // Determine sync start time — last sync or 30 days ago
const since = settings.square_last_sync_at const since = settings.square_last_sync_at
+4 -4
View File
@@ -136,8 +136,8 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
try { try {
// Fetch wholesale products via RPC (avoids rc_product_id bug in direct query) // Fetch wholesale products via RPC (avoids rc_product_id bug in direct query)
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const rpcRes = await fetch( const rpcRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`, `${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
@@ -247,8 +247,8 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
const errors: string[] = []; const errors: string[] = [];
let synced = 0; let synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
let cursor: string | undefined; let cursor: string | undefined;
do { do {
+2 -2
View File
@@ -65,8 +65,8 @@ export async function getSyncLog(brandId: string): Promise<{
return { success: false, logs: [] }; return { success: false, logs: [] };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/square_sync_log?brand_id=eq.${brandId}&order=created_at.desc&limit=10`, `${supabaseUrl}/rest/v1/square_sync_log?brand_id=eq.${brandId}&order=created_at.desc&limit=10`,
+9 -9
View File
@@ -30,11 +30,11 @@ export async function createStopsBatch(
return { success: false, created: 0, error: "No brand selected" }; return { success: false, created: 0, error: "No brand selected" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
// Use anon key — admin_create_stops_batch is SECURITY DEFINER so RLS is // Use anon key — admin_create_stops_batch is SECURITY DEFINER so RLS is
// bypassed. This fixes the prior 42501 RLS violation that came from doing // bypassed. This fixes the prior 42501 RLS violation that came from doing
// direct REST inserts against the RLS-blocked `stops` table. // direct REST inserts against the RLS-blocked `stops` table.
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const rows = stops.map((s) => ({ const rows = stops.map((s) => ({
city: s.city, city: s.city,
@@ -74,8 +74,8 @@ export async function publishStop(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, { const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, {
method: "PATCH", method: "PATCH",
@@ -105,8 +105,8 @@ export type StopForSitemap = {
}; };
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> { export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// Get all active stops with their brand slug // Get all active stops with their brand slug
const response = await fetch( const response = await fetch(
@@ -127,7 +127,7 @@ export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
* Fetch active stops for a brand by slug. * Fetch active stops for a brand by slug.
* Public action used by the storefront stop pages (e.g. /tuxedo/stops, * Public action used by the storefront stop pages (e.g. /tuxedo/stops,
* /indian-river-direct/stops) replaces the previous client-side * /indian-river-direct/stops) replaces the previous client-side
* `supabase.from("stops")` query so supabase-js no longer ships to * `api.from("stops")` query so api-js no longer ships to
* the browser for those routes. * the browser for those routes.
* *
* Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')` * Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')`
@@ -150,8 +150,8 @@ export async function getPublicStopsForBrand(
): Promise<PublicStop[]> { ): Promise<PublicStop[]> {
if (!brandSlug) return []; if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`, `${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
+5 -13
View File
@@ -3,9 +3,6 @@
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
import { getMockTableData } from "@/lib/mock-data";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type CreateStopResult = export type CreateStopResult =
| { success: true; id: string } | { success: true; id: string }
@@ -33,18 +30,13 @@ export async function createStop(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
if (useMockData) { const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const mockStops = getMockTableData("stops") as Array<{ id: string }>;
return { success: true, id: `mock-stop-${Date.now()}` };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
// Preferred path: anon key + SECURITY DEFINER RPC (bypasses RLS, works even if // Preferred path: anon key + SECURITY DEFINER RPC (bypasses RLS, works even if
// service role key is absent at runtime). See: // service role key is absent at runtime). See:
// supabase/migrations/202_fix_admin_create_stop.sql // api/migrations/202_fix_admin_create_stop.sql
// scripts/apply-admin-create-stop.js (run this locally with the keys you have) // scripts/apply-admin-create-stop.js (run this locally with the keys you have)
// supabase/ADMIN_CREATE_STOP_FIX.sql (exact prompt SQL for SQL editor) // api/ADMIN_CREATE_STOP_FIX.sql (exact prompt SQL for SQL editor)
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const anonKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stop`, { const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stop`, {
method: "POST", method: "POST",
@@ -114,7 +106,7 @@ export async function createStop(
" 1. On a machine that can reach your database (normal laptop usually works):\n" + " 1. On a machine that can reach your database (normal laptop usually works):\n" +
" node scripts/apply-admin-create-stop.js\n" + " node scripts/apply-admin-create-stop.js\n" +
" 2. Or: npm run migrate:one 202\n" + " 2. Or: npm run migrate:one 202\n" +
" 3. Or apply supabase/migrations/202_fix_admin_create_stop.sql via psql / Supabase CLI with --db-url.\n\n" + " 3. Or apply api/migrations/202_fix_admin_create_stop.sql via psql / Supabase CLI with --db-url.\n\n" +
"After it succeeds, restart your dev server and Add New Stop will work.", "After it succeeds, restart your dev server and Add New Stop will work.",
}; };
} }
+2 -8
View File
@@ -4,8 +4,6 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type UpdateStopResult = export type UpdateStopResult =
| { success: true } | { success: true }
| { success: false; error: string }; | { success: false; error: string };
@@ -33,12 +31,8 @@ export async function updateStop(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
if (useMockData) { const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
return { success: true }; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`; const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`;
+6 -6
View File
@@ -19,8 +19,8 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { is_connected: false, error: "Not authenticated" }; if (!adminUser) return { is_connected: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Get brand's payment settings // Get brand's payment settings
const res = await fetch( const res = await fetch(
@@ -183,8 +183,8 @@ export async function saveStripeConnectAccount(brandId: string, accountId: strin
success: boolean; success: boolean;
error?: string; error?: string;
}> { }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Save to payment_settings via RPC // Save to payment_settings via RPC
const res = await fetch( const res = await fetch(
@@ -220,8 +220,8 @@ export async function disconnectStripeConnect(brandId: string): Promise<{
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/disconnect_stripe_connect`, `${supabaseUrl}/rest/v1/rpc/disconnect_stripe_connect`,
+2 -2
View File
@@ -102,8 +102,8 @@ export async function calculateOrderTax(params: {
} }
async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings | null> { async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings`, `${supabaseUrl}/rest/v1/rpc/get_brand_settings`,
+2 -2
View File
@@ -2,8 +2,8 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
function rpcBody(body: Record<string, unknown>) { function rpcBody(body: Record<string, unknown>) {
return JSON.stringify(body); return JSON.stringify(body);
+2 -111
View File
@@ -2,13 +2,9 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Mock mode flag - only enabled when explicitly set
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
function rpcBody(body: Record<string, unknown>) { function rpcBody(body: Record<string, unknown>) {
return JSON.stringify(body); return JSON.stringify(body);
@@ -62,20 +58,6 @@ export type TimeSummary = {
// ── Workers ─────────────────────────────────────────────────────────────────── // ── Workers ───────────────────────────────────────────────────────────────────
export async function getTimeTrackingWorkers(brandId: string): Promise<TimeWorker[]> { export async function getTimeTrackingWorkers(brandId: string): Promise<TimeWorker[]> {
if (useMockData) {
return mockWorkers.map(w => ({
id: w.id,
brand_id: brandId,
name: w.name,
role: w.role,
lang: w.language,
pin: "0000",
active: w.is_active,
last_used_at: null,
created_at: new Date().toISOString(),
worker_number: null,
}));
}
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_workers`, `${supabaseUrl}/rest/v1/rpc/get_time_tracking_workers`,
{ {
@@ -182,16 +164,6 @@ export async function deleteTimeWorker(workerId: string): Promise<{ success: boo
// ── Tasks ──────────────────────────────────────────────────────────────────── // ── Tasks ────────────────────────────────────────────────────────────────────
export async function getTimeTrackingTasks(brandId: string, activeOnly = false): Promise<TimeTask[]> { export async function getTimeTrackingTasks(brandId: string, activeOnly = false): Promise<TimeTask[]> {
if (useMockData) {
return mockTasks.map(t => ({
id: t.id,
name: t.name_en,
name_es: t.name_es,
unit: t.unit,
active: true,
sort_order: t.sort_order,
}));
}
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_tasks`, `${supabaseUrl}/rest/v1/rpc/get_time_tracking_tasks`,
{ {
@@ -282,30 +254,6 @@ export async function getWorkerTimeLogs(
offset?: number; offset?: number;
} = {} } = {}
): Promise<TimeLog[]> { ): Promise<TimeLog[]> {
if (useMockData) {
// Filter by worker, task, date range
let entries = mockTimeEntries.filter(e => e.brand_id === brandId);
if (options.workerId) entries = entries.filter(e => e.worker_id === options.workerId);
return entries.map(e => {
const worker = mockWorkers.find(w => w.id === e.worker_id);
const task = mockTasks.find(t => t.id === e.task_id);
return {
id: e.id,
worker_id: e.worker_id,
worker_name: worker?.name ?? "Unknown",
task_id: e.task_id,
task_name: task?.name_en ?? "Unknown",
clock_in: `${e.date}T09:00:00Z`,
clock_out: `${e.date}T${9 + e.hours}:00:00Z`,
lunch_break_minutes: 0,
notes: null,
submitted_via: "web",
total_minutes: Math.round(e.hours * 60),
created_at: new Date().toISOString(),
};
});
}
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_worker_time_logs`, `${supabaseUrl}/rest/v1/rpc/get_worker_time_logs`,
{ {
@@ -380,39 +328,6 @@ export async function getTimeTrackingSummary(
start: string, start: string,
end: string end: string
): Promise<TimeSummary> { ): Promise<TimeSummary> {
if (useMockData) {
const entries = mockTimeEntries.filter(e => e.brand_id === brandId);
// Calculate by worker
const workerMap = new Map<string, { id: string; name: string; total_hours: number; entry_count: number }>();
entries.forEach(e => {
const worker = mockWorkers.find(w => w.id === e.worker_id);
const existing = workerMap.get(e.worker_id) || { id: e.worker_id, name: worker?.name ?? "Unknown", total_hours: 0, entry_count: 0 };
existing.total_hours += e.hours;
existing.entry_count += 1;
workerMap.set(e.worker_id, existing);
});
// Calculate by task
const taskMap = new Map<string, { id: string; name: string; name_es: string | null; total_hours: number; entry_count: number }>();
entries.forEach(e => {
const task = mockTasks.find(t => t.id === e.task_id);
const existing = taskMap.get(e.task_id) || { id: e.task_id, name: task?.name_en ?? "Unknown", name_es: task?.name_es ?? null, total_hours: 0, entry_count: 0 };
existing.total_hours += e.hours;
existing.entry_count += 1;
taskMap.set(e.task_id, existing);
});
return {
by_worker: Array.from(workerMap.values()),
by_task: Array.from(taskMap.values()),
totals: {
entry_count: entries.length,
total_hours: entries.reduce((sum, e) => sum + e.hours, 0),
open_count: 0,
},
};
}
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_summary`, `${supabaseUrl}/rest/v1/rpc/get_time_tracking_summary`,
{ {
@@ -447,26 +362,6 @@ export type TimeTrackingSettings = {
}; };
export async function getTimeTrackingSettings(brandId: string): Promise<TimeTrackingSettings | null> { export async function getTimeTrackingSettings(brandId: string): Promise<TimeTrackingSettings | null> {
if (useMockData) {
return {
id: "settings-mock",
brand_id: brandId,
pay_period_start_day: 0,
pay_period_length_days: 7,
daily_overtime_threshold: 8,
weekly_overtime_threshold: 40,
overtime_multiplier: 1.5,
overtime_notifications: true,
notification_emails: ["admin@tuxedocorn.com"],
notification_sms_numbers: [],
enable_daily_alerts: true,
enable_weekly_alerts: true,
daily_alert_threshold: 8,
weekly_alert_threshold: 40,
send_end_of_period_summary: true,
brand_name: "Tuxedo Corn",
};
}
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_settings`, `${supabaseUrl}/rest/v1/rpc/get_time_tracking_settings`,
{ {
@@ -552,10 +447,6 @@ export async function getTimeTrackingNotificationLog(
brandId: string, brandId: string,
limit = 100 limit = 100
): Promise<NotificationLogEntry[]> { ): Promise<NotificationLogEntry[]> {
if (useMockData) {
// Return empty log in mock mode
return [];
}
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_notification_log`, `${supabaseUrl}/rest/v1/rpc/get_time_tracking_notification_log`,
{ {
+2 -2
View File
@@ -3,8 +3,8 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
export type OvertimeCheckResult = { export type OvertimeCheckResult = {
sent: boolean; sent: boolean;
+32 -32
View File
@@ -50,8 +50,8 @@ export async function createWaterHeadgate(brandId: string, name: string, unit: s
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // prefer service for admin muts const supabaseKey = process.env.POSTGREST_SERVICE_KEY!; // prefer service for admin muts
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_water_headgate`, `${supabaseUrl}/rest/v1/rpc/create_water_headgate`,
@@ -81,8 +81,8 @@ export async function updateWaterHeadgate(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_water_headgate`, `${supabaseUrl}/rest/v1/rpc/update_water_headgate`,
@@ -111,8 +111,8 @@ export async function updateWaterHeadgate(
// ── Irrigator Admin ───────────────────────────────────────── // ── Irrigator Admin ─────────────────────────────────────────
export async function getWaterIrrigators(brandId: string): Promise<Irrigator[]> { export async function getWaterIrrigators(brandId: string): Promise<Irrigator[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_users`, `${supabaseUrl}/rest/v1/rpc/get_water_users`,
@@ -142,8 +142,8 @@ export async function createWaterUser(
role: "irrigator" | "water_admin", role: "irrigator" | "water_admin",
lang: string = "en" lang: string = "en"
): Promise<{ success: boolean; user?: Irrigator; pin?: string; error?: string }> { ): Promise<{ success: boolean; user?: Irrigator; pin?: string; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_water_user`, `${supabaseUrl}/rest/v1/rpc/create_water_user`,
@@ -172,8 +172,8 @@ export async function updateWaterIrrigator(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_water_user`, `${supabaseUrl}/rest/v1/rpc/update_water_user`,
@@ -205,8 +205,8 @@ export async function resetWaterIrrigatorPin(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/reset_water_user_pin`, `${supabaseUrl}/rest/v1/rpc/reset_water_user_pin`,
@@ -232,8 +232,8 @@ export async function deleteWaterUser(userId: string): Promise<{ success: boolea
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_water_user`, `${supabaseUrl}/rest/v1/rpc/delete_water_user`,
@@ -259,8 +259,8 @@ export async function deleteWaterHeadgate(headgateId: string): Promise<{ success
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_water_headgate`, `${supabaseUrl}/rest/v1/rpc/delete_water_headgate`,
@@ -308,8 +308,8 @@ export async function deleteWaterHeadgate(headgateId: string): Promise<{ success
// ── Entries ──────────────────────────────────────────────── // ── Entries ────────────────────────────────────────────────
export async function getWaterEntries(brandId: string, limit = 50): Promise<WaterEntry[]> { export async function getWaterEntries(brandId: string, limit = 50): Promise<WaterEntry[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_entries`, `${supabaseUrl}/rest/v1/rpc/get_water_entries`,
@@ -326,8 +326,8 @@ export async function getWaterEntries(brandId: string, limit = 50): Promise<Wate
} }
export async function getWaterHeadgatesAdmin(brandId: string): Promise<Headgate[]> { export async function getWaterHeadgatesAdmin(brandId: string): Promise<Headgate[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_headgates_admin`, `${supabaseUrl}/rest/v1/rpc/get_water_headgates_admin`,
@@ -348,8 +348,8 @@ export async function regenerateHeadgateToken(headgateId: string): Promise<{ suc
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/regenerate_headgate_token`, `${supabaseUrl}/rest/v1/rpc/regenerate_headgate_token`,
@@ -379,8 +379,8 @@ export async function updateWaterEntry(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_water_entry`, `${supabaseUrl}/rest/v1/rpc/update_water_entry`,
@@ -411,8 +411,8 @@ export async function deleteWaterEntry(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_water_entry`, `${supabaseUrl}/rest/v1/rpc/delete_water_entry`,
@@ -434,8 +434,8 @@ export async function deleteWaterEntry(
} }
export async function getWaterEntryById(entryId: string): Promise<WaterEntry | null> { export async function getWaterEntryById(entryId: string): Promise<WaterEntry | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_entry_by_id`, `${supabaseUrl}/rest/v1/rpc/get_water_entry_by_id`,
@@ -481,8 +481,8 @@ export type WaterDisplaySummary = {
}; };
export async function getWaterDisplaySummary(brandId: string): Promise<WaterDisplaySummary | null> { export async function getWaterDisplaySummary(brandId: string): Promise<WaterDisplaySummary | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_display_summary`, `${supabaseUrl}/rest/v1/rpc/get_water_display_summary`,
@@ -511,8 +511,8 @@ export type AlertLogEntry = {
}; };
export async function getWaterAlertLog(brandId: string, limit = 50): Promise<AlertLogEntry[]> { export async function getWaterAlertLog(brandId: string, limit = 50): Promise<AlertLogEntry[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_alert_log`, `${supabaseUrl}/rest/v1/rpc/get_water_alert_log`,
+8 -8
View File
@@ -35,8 +35,8 @@ type HeadgatesResult = {
}; };
export async function getWaterHeadgates(brandId: string, activeOnly = false): Promise<Headgate[]> { export async function getWaterHeadgates(brandId: string, activeOnly = false): Promise<Headgate[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_headgates`, `${supabaseUrl}/rest/v1/rpc/get_water_headgates`,
@@ -53,8 +53,8 @@ export async function getWaterHeadgates(brandId: string, activeOnly = false): Pr
} }
export async function verifyWaterPin(brandId: string, pin: string): Promise<VerifyPinResult> { export async function verifyWaterPin(brandId: string, pin: string): Promise<VerifyPinResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/verify_water_pin`, `${supabaseUrl}/rest/v1/rpc/verify_water_pin`,
@@ -134,8 +134,8 @@ export async function submitWaterEntry(
return { success: false, error: "Not logged in" }; return { success: false, error: "Not logged in" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/submit_water_entry`, `${supabaseUrl}/rest/v1/rpc/submit_water_entry`,
@@ -236,8 +236,8 @@ export async function getWaterAdminSession(): Promise<{ user_id: string; name: s
if (!sessionId) return null; if (!sessionId) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// Use get_water_admin_session RPC (SECURITY DEFINER) to avoid direct water_sessions + water_users JOIN // Use get_water_admin_session RPC (SECURITY DEFINER) to avoid direct water_sessions + water_users JOIN
const response = await fetch( const response = await fetch(
+6 -6
View File
@@ -14,8 +14,8 @@ export type WaterAdminSettings = {
}; };
export async function getWaterAdminSettings(brandId: string): Promise<WaterAdminSettings | null> { export async function getWaterAdminSettings(brandId: string): Promise<WaterAdminSettings | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_admin_settings`, `${supabaseUrl}/rest/v1/rpc/get_water_admin_settings`,
@@ -39,8 +39,8 @@ export async function saveWaterAdminSettings(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
let pinHash: string | null = null; let pinHash: string | null = null;
if (settings.pin) { if (settings.pin) {
@@ -88,8 +88,8 @@ export async function verifyWaterAdminPin(
brandId: string, brandId: string,
pin: string pin: string
): Promise<{ success: boolean; session_id?: string; error?: string }> { ): Promise<{ success: boolean; session_id?: string; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const settingsRes = await fetch( const settingsRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_admin_settings`, `${supabaseUrl}/rest/v1/rpc/get_water_admin_settings`,
+42 -111
View File
@@ -1,8 +1,7 @@
"use server"; "use server";
import { cookies } from "next/headers"; import { cookies, headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth";
import { svcHeaders } from "@/lib/svc-headers";
export type WholesaleLoginResult = export type WholesaleLoginResult =
| { success: true; token: string; userId: string; customerId: string } | { success: true; token: string; userId: string; customerId: string }
@@ -12,121 +11,53 @@ export async function wholesaleLoginAction(formData: FormData): Promise<Wholesal
const email = formData.get("email") as string; const email = formData.get("email") as string;
const password = formData.get("password") as string; const password = formData.get("password") as string;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const hdrs = await headers();
const result = await auth.api.signInEmail({
body: { email, password },
headers: hdrs,
asResponse: false,
});
const cookieStore = await cookies(); if (!result?.user) {
const request = new NextRequest("http://localhost/wholesale/login", { return { success: false, error: "Invalid credentials" };
headers: new Headers(),
});
const response = NextResponse.next({ request });
const { createServerClient } = await import("@supabase/ssr");
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);
});
},
},
});
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 no brand_id known, try all brands — just use first active one found const cookieStore = await cookies();
// For now, set the cookie with user_id and a placeholder; portal will resolve proper customer // Better Auth sets its own session cookie (rc_session_token).
response.cookies.set("wholesale_session", JSON.stringify({ // Mark wholesale session for portal routing.
user_id: data.user.id, cookieStore.set("wholesale_session", JSON.stringify({
access_token: token, user_id: result.user.id,
}), { }), {
path: "/", path: "/",
maxAge: 3600 * 24 * 7, maxAge: 3600 * 24 * 7,
sameSite: "lax", sameSite: "lax",
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
httpOnly: false, httpOnly: false,
}); });
// Also set the standard auth token for RPC calls return {
response.cookies.set("sb-wnzkhezyhnfzhkhiflrp-auth-token", token, { success: true,
path: "/", token: "better-auth-session", // session lives in cookie
maxAge: 3600 * 24 * 7, userId: result.user.id,
sameSite: "lax", customerId: "pending", // resolved by portal page on load
secure: process.env.NODE_ENV === "production", };
httpOnly: false, } catch (e: unknown) {
}); const message = e instanceof Error ? e.message : "Invalid credentials";
return { success: false, error: message };
return { }
success: true,
token,
userId: data.user.id,
customerId: "pending", // resolved by portal page on load
};
} }
export async function wholesaleLogoutAction() { export async function wholesaleLogoutAction() {
try {
const hdrs = await headers();
await auth.api.signOut({ headers: hdrs });
} catch {
// best-effort
}
const cookieStore = await cookies(); const cookieStore = await cookies();
const request = new NextRequest("http://localhost/wholesale/portal", { cookieStore.delete("wholesale_session");
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();
return { success: true }; return { success: true };
} }
+24 -24
View File
@@ -10,8 +10,8 @@ export async function registerWholesaleCustomer(params: {
email: string; email: string;
phone?: string; phone?: string;
}): Promise<{ success: boolean; error?: string; requiresApproval?: boolean }> { }): Promise<{ success: boolean; error?: string; requiresApproval?: boolean }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/register_wholesale_customer`, `${supabaseUrl}/rest/v1/rpc/register_wholesale_customer`,
@@ -54,8 +54,8 @@ export async function getPendingWholesaleRegistrations(brandId: string) {
if (!adminUser) return []; if (!adminUser) return [];
if (!adminUser.can_manage_orders) return []; if (!adminUser.can_manage_orders) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_pending_wholesale_registrations`, `${supabaseUrl}/rest/v1/rpc/get_pending_wholesale_registrations`,
@@ -85,8 +85,8 @@ export async function approveWholesaleRegistration(
return { success: false, error: "Not authorized to operate on this brand" }; return { success: false, error: "Not authorized to operate on this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/approve_wholesale_registration`, `${supabaseUrl}/rest/v1/rpc/approve_wholesale_registration`,
@@ -128,8 +128,8 @@ export async function getWholesaleCustomerByUser(
role: string; role: string;
brand_id: string; brand_id: string;
} | null> { } | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`, `${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`,
@@ -162,8 +162,8 @@ export async function getWholesaleCustomer(
role: string; role: string;
brand_id: string; brand_id: string;
} | null> { } | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=id,user_id,company_name,contact_name,email,phone,account_status,role,brand_id`, `${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=id,user_id,company_name,contact_name,email,phone,account_status,role,brand_id`,
@@ -179,8 +179,8 @@ export async function getWholesaleCustomer(
export async function getWholesaleProducts(brandId: string) { export async function getWholesaleProducts(brandId: string) {
// Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too // Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`, `${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
@@ -217,8 +217,8 @@ export async function submitWholesaleOrder(params: {
orderTotal?: number; orderTotal?: number;
idempotent?: boolean; idempotent?: boolean;
}> { }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// Generate UUID at the start of checkout — before RPC call for true idempotency // Generate UUID at the start of checkout — before RPC call for true idempotency
const checkoutSessionId = params.checkoutSessionId ?? crypto.randomUUID(); const checkoutSessionId = params.checkoutSessionId ?? crypto.randomUUID();
@@ -284,8 +284,8 @@ export async function submitWholesaleOrder(params: {
export async function enqueueWholesaleWebhookForOrderCreated(orderId: string, brandId: string, customerId: string, invoiceNumber: string | null, subtotal: number) { export async function enqueueWholesaleWebhookForOrderCreated(orderId: string, brandId: string, customerId: string, invoiceNumber: string | null, subtotal: number) {
try { try {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY ?? process.env.SUPABASE_ANON_KEY!;
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, { await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
method: "POST", method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
@@ -341,8 +341,8 @@ export type WholesalePricingOverride = {
}; };
export async function getWholesaleCustomerPricing(customerId: string): Promise<WholesalePricingOverride[]> { export async function getWholesaleCustomerPricing(customerId: string): Promise<WholesalePricingOverride[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_pricing`, `${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_pricing`,
@@ -365,8 +365,8 @@ export async function upsertWholesaleCustomerPricing(params: {
productId: string; productId: string;
customUnitPrice: number; customUnitPrice: number;
}): Promise<{ success: boolean; error?: string }> { }): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer_pricing`, `${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer_pricing`,
@@ -392,8 +392,8 @@ export async function deleteWholesaleCustomerPricing(params: {
customerId: string; customerId: string;
productId: string; productId: string;
}): Promise<{ success: boolean; error?: string }> { }): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer_pricing`, `${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer_pricing`,
@@ -415,8 +415,8 @@ export async function deleteWholesaleCustomerPricing(params: {
} }
export async function getWholesaleCustomerOrders(customerId: string): Promise<WholesaleCustomerOrder[]> { export async function getWholesaleCustomerOrders(customerId: string): Promise<WholesaleCustomerOrder[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_orders`, `${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_orders`,
+52 -52
View File
@@ -187,8 +187,8 @@ export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrd
if (!adminUser) return []; if (!adminUser) return [];
const bid = resolveBrandId(adminUser, brandId); const bid = resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, `${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
@@ -211,8 +211,8 @@ export async function getWholesalePickupOrders(brandId?: string): Promise<Wholes
if (!adminUser) return []; if (!adminUser) return [];
const bid = resolveBrandId(adminUser, brandId); const bid = resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_pickup_orders`, `${supabaseUrl}/rest/v1/rpc/get_wholesale_pickup_orders`,
@@ -259,8 +259,8 @@ export async function markWholesaleOrderFulfilled(orderId: string, brandId?: str
const { brandId: resolved, error } = enforceBrandScope(adminUser, brandId); const { brandId: resolved, error } = enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/mark_wholesale_order_fulfilled`, `${supabaseUrl}/rest/v1/rpc/mark_wholesale_order_fulfilled`,
@@ -282,8 +282,8 @@ export async function markWholesaleOrderFulfilled(orderId: string, brandId?: str
} }
async function enqueueWholesaleWebhookForOrderFulfilled(orderId: string, brandId?: string) { async function enqueueWholesaleWebhookForOrderFulfilled(orderId: string, brandId?: string) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY ?? process.env.SUPABASE_ANON_KEY!;
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, { await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
method: "POST", method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
@@ -308,8 +308,8 @@ export async function updateWholesaleOrderStatus(
const { error } = enforceBrandScope(adminUser, brandId); const { error } = enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_wholesale_order_status`, `${supabaseUrl}/rest/v1/rpc/update_wholesale_order_status`,
@@ -335,8 +335,8 @@ export async function deleteWholesaleOrder(orderId: string, brandId?: string): P
const { error } = enforceBrandScope(adminUser, brandId); const { error } = enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_order`, `${supabaseUrl}/rest/v1/rpc/delete_wholesale_order`,
@@ -362,8 +362,8 @@ export async function deleteWholesaleCustomer(customerId: string, brandId?: stri
const { error } = enforceBrandScope(adminUser, brandId); const { error } = enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer`, `${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer`,
@@ -391,8 +391,8 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
const { error } = enforceBrandScope(adminUser, brandId); const { error } = enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_product`, `${supabaseUrl}/rest/v1/rpc/delete_wholesale_product`,
@@ -419,8 +419,8 @@ export async function getWholesaleCustomers(brandId?: string): Promise<Wholesale
if (!adminUser) return []; if (!adminUser) return [];
const bid = resolveBrandId(adminUser, brandId); const bid = resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customers`, `${supabaseUrl}/rest/v1/rpc/get_wholesale_customers`,
@@ -461,8 +461,8 @@ export async function saveWholesaleCustomer(params: {
const { error } = enforceBrandScope(adminUser, params.brandId); const { error } = enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer`, `${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer`,
@@ -503,8 +503,8 @@ export async function getWholesaleProducts(brandId?: string): Promise<WholesaleP
if (!adminUser) return []; if (!adminUser) return [];
const bid = resolveBrandId(adminUser, brandId); const bid = resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`, `${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
@@ -548,8 +548,8 @@ export async function saveWholesaleProduct(params: {
const { error } = enforceBrandScope(adminUser, params.brandId); const { error } = enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_product`, `${supabaseUrl}/rest/v1/rpc/upsert_wholesale_product`,
@@ -593,8 +593,8 @@ export async function getWholesaleSettings(brandId?: string): Promise<WholesaleS
if (!adminUser) return null; if (!adminUser) return null;
const bid = brandId ?? adminUser.brand_id ?? null; const bid = brandId ?? adminUser.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, `${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`,
@@ -635,8 +635,8 @@ export async function saveWholesaleSettings(params: {
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_settings`, `${supabaseUrl}/rest/v1/rpc/upsert_wholesale_settings`,
@@ -683,8 +683,8 @@ export async function recordWholesaleDeposit(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/record_wholesale_deposit`, `${supabaseUrl}/rest/v1/rpc/record_wholesale_deposit`,
@@ -716,8 +716,8 @@ export async function recordWholesaleDeposit(
} }
async function enqueueWholesaleWebhookForDepositRecorded(orderId: string, amount: number, brandId?: string) { async function enqueueWholesaleWebhookForDepositRecorded(orderId: string, amount: number, brandId?: string) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY ?? process.env.SUPABASE_ANON_KEY!;
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, { await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
method: "POST", method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
@@ -739,8 +739,8 @@ export async function bulkFulfillWholesaleOrders(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/bulk_fulfill_wholesale_orders`, `${supabaseUrl}/rest/v1/rpc/bulk_fulfill_wholesale_orders`,
@@ -774,8 +774,8 @@ export async function bulkRecordWholesaleDeposit(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/bulk_record_wholesale_deposit`, `${supabaseUrl}/rest/v1/rpc/bulk_record_wholesale_deposit`,
@@ -824,8 +824,8 @@ export type WholesaleNotification = {
export async function getWholesaleNotificationStats( export async function getWholesaleNotificationStats(
brandId: string brandId: string
): Promise<{ pending: number; sent: number; failed: number; total: number }> { ): Promise<{ pending: number; sent: number; failed: number; total: number }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_notification_stats`, `${supabaseUrl}/rest/v1/rpc/get_wholesale_notification_stats`,
@@ -851,8 +851,8 @@ export async function getWholesalePendingNotifications(
if (!adminUser) return []; if (!adminUser) return [];
if (!adminUser.can_manage_orders) return []; if (!adminUser.can_manage_orders) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_pending_notifications`, `${supabaseUrl}/rest/v1/rpc/get_wholesale_pending_notifications`,
@@ -878,8 +878,8 @@ export async function markWholesaleNotificationSent(
if (!adminUser) return { success: false }; if (!adminUser) return { success: false };
if (!adminUser.can_manage_orders) return { success: false }; if (!adminUser.can_manage_orders) return { success: false };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/mark_wholesale_notification_sent`, `${supabaseUrl}/rest/v1/rpc/mark_wholesale_notification_sent`,
@@ -907,8 +907,8 @@ export async function enqueueWholesaleNotification(params: {
bodyHtml?: string; bodyHtml?: string;
bodyText?: string; bodyText?: string;
}): Promise<{ success: boolean; error?: string }> { }): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`, `${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`,
@@ -949,8 +949,8 @@ export type WebhookSettings = {
}; };
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> { export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/wholesale_webhook_settings?brand_id=eq.${brandId}&select=*`, `${supabaseUrl}/rest/v1/wholesale_webhook_settings?brand_id=eq.${brandId}&select=*`,
@@ -974,8 +974,8 @@ export async function saveWebhookSettings(params: {
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
brand_id: params.brandId, brand_id: params.brandId,
@@ -1007,8 +1007,8 @@ export async function enqueueWholesaleWebhook(
payload: Record<string, unknown> | null = null, payload: Record<string, unknown> | null = null,
brandId?: string brandId?: string
): Promise<{ success: boolean; logId?: string }> { ): Promise<{ success: boolean; logId?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY ?? process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, `${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`,
@@ -1041,8 +1041,8 @@ export async function getRecentWebhookActivity(brandId: string, limit = 10): Pro
created_at: string; created_at: string;
response: string | null; response: string | null;
}>> { }>> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.POSTGREST_SERVICE_KEY ?? process.env.NEXT_PUBLIC_API_ANON_KEY!;
const res = await fetch( const res = await fetch(
`${supabaseUrl}/rest/v1/wholesale_sync_log?brand_id=eq.${brandId}&order=created_at.desc&limit=${limit}`, `${supabaseUrl}/rest/v1/wholesale_sync_log?brand_id=eq.${brandId}&order=created_at.desc&limit=${limit}`,
+2 -2
View File
@@ -11,8 +11,8 @@ export default async function DebugAuthPage() {
let adminUsersResult: string | null = null; let adminUsersResult: string | null = null;
if (rcAuthUid) { if (rcAuthUid) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const serviceKey = process.env.POSTGREST_SERVICE_KEY;
if (supabaseUrl && serviceKey) { if (supabaseUrl && serviceKey) {
try { try {
const res = await fetch( const res = await fetch(
+12 -11
View File
@@ -2,9 +2,10 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { supabase } from "@/lib/supabase"; import { authClient } from "@/lib/auth-client";
import { AdminUserRow } from "@/actions/admin/users"; import { AdminUserRow } from "@/actions/admin/users";
import { logUserActivity } from "@/actions/admin/audit"; import { logUserActivity } from "@/actions/admin/audit";
import { updateAdminProfileAction } from "@/actions/admin/profile";
type ProfilePageProps = { type ProfilePageProps = {
currentUser: AdminUserRow; currentUser: AdminUserRow;
@@ -26,13 +27,13 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
const { error: rpcError } = await supabase.rpc("update_admin_user", { const result = await updateAdminProfileAction(
p_id: currentUser.id, currentUser.id,
p_display_name: displayName || null, displayName || null,
p_phone_number: phoneNumber || null, phoneNumber || null
}); );
if (rpcError) { if (!result.success) {
setError(rpcError.message); setError(result.error);
return; return;
} }
await logUserActivity({ await logUserActivity({
@@ -51,11 +52,11 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
setChangingEmail(true); setChangingEmail(true);
setEmailError(null); setEmailError(null);
try { try {
const { error: updateError } = await supabase.auth.updateUser({ const { error: updateError } = await authClient.changeEmail({
email: newEmail, newEmail: newEmail,
}); });
if (updateError) { if (updateError) {
setEmailError(updateError.message); setEmailError(updateError.message ?? "Failed to change email");
return; return;
} }
await logUserActivity({ await logUserActivity({
+2 -2
View File
@@ -4,7 +4,7 @@ import { getAdminOrders } from "@/actions/orders";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system"; import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase"; import { api } from "@/lib/api";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -33,7 +33,7 @@ export default async function AdminOrdersPage() {
// Fetch active products for this brand (for admin "New Order" item picker) // Fetch active products for this brand (for admin "New Order" item picker)
let brandProducts: Array<{ id: string; name: string; price: number; type?: string | null; active?: boolean }> = []; let brandProducts: Array<{ id: string; name: string; price: number; type?: string | null; active?: boolean }> = [];
try { try {
let prodQuery = supabase let prodQuery = api
.from("products") .from("products")
.select("id, name, price, type, active") .select("id, name, price, type, active")
.eq("active", true) .eq("active", true)
+2 -2
View File
@@ -1,5 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { supabase } from "@/lib/supabase"; import { api } from "@/lib/api";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { isFeatureEnabled } from "@/lib/feature-flags"; import { isFeatureEnabled } from "@/lib/feature-flags";
import { getBillingOverview } from "@/actions/billing/billing-overview"; import { getBillingOverview } from "@/actions/billing/billing-overview";
@@ -30,7 +30,7 @@ export default async function AdminPage() {
// (0/0/0) usage values and contradicts the billing page's "Products 1/25". // (0/0/0) usage values and contradicts the billing page's "Products 1/25".
let dashboardBrandId: string | null = adminUser?.brand_id ?? null; let dashboardBrandId: string | null = adminUser?.brand_id ?? null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") { if (!dashboardBrandId && adminUser?.role === "platform_admin") {
const { data: firstBrand } = await supabase const { data: firstBrand } = await api
.from("brands") .from("brands")
.select("id") .select("id")
.limit(1) .limit(1)
+5 -5
View File
@@ -1,4 +1,4 @@
import { supabase } from "@/lib/supabase"; import { api } from "@/lib/api";
import ProductEditForm from "@/components/admin/ProductEditForm"; import ProductEditForm from "@/components/admin/ProductEditForm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
@@ -14,10 +14,10 @@ type ProductDetailPageProps = {
export default async function ProductDetailPage({ params }: ProductDetailPageProps) { export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
const { id } = await params; const { id } = await params;
const [{ data: product, error }, { data: brands }] = await Promise.all([ const [{ data: product, error }, { data: brands }] = (await Promise.all([
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single(), api.from("products").select("*, brands(name, slug)").eq("id", id).single(),
supabase.from("brands").select("id, name, slug"), api.from("brands").select("id, name, slug"),
]); ])) as any;
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
+3 -3
View File
@@ -1,4 +1,4 @@
import { supabase } from "@/lib/supabase"; import { api } from "@/lib/api";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import ProductsClient from "@/components/admin/ProductsClient"; import ProductsClient from "@/components/admin/ProductsClient";
@@ -31,7 +31,7 @@ export default async function AdminProductsPage() {
const brandId = adminUser.brand_id; const brandId = adminUser.brand_id;
let query = supabase let query = api
.from("products") .from("products")
.select(` .select(`
id, id,
@@ -52,7 +52,7 @@ export default async function AdminProductsPage() {
query = query.eq("brand_id", adminUser.brand_id); query = query.eq("brand_id", adminUser.brand_id);
} }
const { data: products, error } = await query; const { data: products, error } = (await query) as any;
if (error) { if (error) {
return ( return (
+2 -2
View File
@@ -1,4 +1,4 @@
import { supabase } from "@/lib/supabase"; import { api } from "@/lib/api";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import ReportsDashboard from "@/components/admin/ReportsDashboard"; import ReportsDashboard from "@/components/admin/ReportsDashboard";
@@ -12,7 +12,7 @@ export default async function ReportsPage() {
const isPlatformAdmin = adminUser.role === "platform_admin"; const isPlatformAdmin = adminUser.role === "platform_admin";
const { data: brands } = await supabase const { data: brands } = await api
.from("brands") .from("brands")
.select("id, name") .select("id, name")
.order("name"); .order("name");
+2 -2
View File
@@ -1,4 +1,4 @@
import { supabase } from "@/lib/supabase"; import { api } from "@/lib/api";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getBillingOverview } from "@/actions/billing/billing-overview"; import { getBillingOverview } from "@/actions/billing/billing-overview";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
@@ -18,7 +18,7 @@ export default async function BillingPage({ params }: Props) {
let resolvedBrandId = effectiveBrandId; let resolvedBrandId = effectiveBrandId;
if (isPlatformAdmin && !resolvedBrandId) { if (isPlatformAdmin && !resolvedBrandId) {
const { data: firstBrand } = await supabase const { data: firstBrand } = await api
.from("brands") .from("brands")
.select("id") .select("id")
.limit(1) .limit(1)
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase"; import { api } from "@/lib/api";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import IntegrationsClientPage from "./IntegrationsClientPage"; import IntegrationsClientPage from "./IntegrationsClientPage";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
@@ -18,7 +18,7 @@ export default async function IntegrationsPage() {
// Platform admins: fetch all brands for the picker // Platform admins: fetch all brands for the picker
const brands = isPlatformAdmin const brands = isPlatformAdmin
? (await supabase.from("brands").select("id, name").order("name")).data ?? [] ? (await api.from("brands").select("id, name").order("name")).data ?? []
: []; : [];
return ( return (
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase"; import { api } from "@/lib/api";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getShippingSettings } from "@/actions/shipping/settings"; import { getShippingSettings } from "@/actions/shipping/settings";
import ShippingSettingsForm from "@/components/admin/ShippingSettingsForm"; import ShippingSettingsForm from "@/components/admin/ShippingSettingsForm";
@@ -17,7 +17,7 @@ export default async function ShippingSettingsPage() {
// Platform admins: fetch all brands for the picker // Platform admins: fetch all brands for the picker
const brands = isPlatformAdmin const brands = isPlatformAdmin
? (await supabase.from("brands").select("id, name").order("name")).data ?? [] ? (await api.from("brands").select("id, name").order("name")).data ?? []
: []; : [];
const effectiveBrandId = brandId || (brands[0]?.id ?? ""); const effectiveBrandId = brandId || (brands[0]?.id ?? "");
+9 -9
View File
@@ -1,4 +1,4 @@
import { supabase } from "@/lib/supabase"; import { api } from "@/lib/api";
import StopEditForm from "@/components/admin/StopEditForm"; import StopEditForm from "@/components/admin/StopEditForm";
import StopProductAssignment from "@/components/admin/StopProductAssignment"; import StopProductAssignment from "@/components/admin/StopProductAssignment";
import MessageCustomersSection from "@/components/admin/MessageCustomersSection"; import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
@@ -16,14 +16,14 @@ type StopDetailPageProps = {
export default async function StopDetailPage({ params }: StopDetailPageProps) { export default async function StopDetailPage({ params }: StopDetailPageProps) {
const { id } = await params; const { id } = await params;
const [{ data: stop, error }, { data: brands }] = await Promise.all([ const [{ data: stop, error }, { data: brands }] = (await Promise.all([
supabase api
.from("stops") .from("stops")
.select("*, brands(name, slug)") .select("*, brands(name, slug)")
.eq("id", id) .eq("id", id)
.single(), .single(),
supabase.from("brands").select("id, name, slug"), api.from("brands").select("id, name, slug"),
]); ])) as any;
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
@@ -56,17 +56,17 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
); );
} }
const [{ data: allProducts }, { data: productStops }] = await Promise.all([ const [{ data: allProducts }, { data: productStops }] = (await Promise.all([
supabase api
.from("products") .from("products")
.select("id, name, type, price") .select("id, name, type, price")
.eq("brand_id", stop.brand_id) .eq("brand_id", stop.brand_id)
.eq("active", true), .eq("active", true),
supabase api
.from("product_stops") .from("product_stops")
.select("id, product_id, products(id, name, type, price)") .select("id, product_id, products(id, name, type, price)")
.eq("stop_id", id), .eq("stop_id", id),
]); ])) as any;
const assignedProducts = (productStops ?? []) const assignedProducts = (productStops ?? [])
.map((ps: any) => ps) .map((ps: any) => ps)
+4 -4
View File
@@ -1,4 +1,4 @@
import { supabase } from "@/lib/supabase"; import { api } from "@/lib/api";
import NewStopForm from "@/components/admin/NewStopForm"; import NewStopForm from "@/components/admin/NewStopForm";
import StopProductAssignment from "@/components/admin/StopProductAssignment"; import StopProductAssignment from "@/components/admin/StopProductAssignment";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
@@ -32,11 +32,11 @@ export default async function NewStopPage({
let duplicateFrom: Stop | null = null; let duplicateFrom: Stop | null = null;
if (duplicate) { if (duplicate) {
const { data } = await supabase const { data } = (await api
.from("stops") .from("stops")
.select("city, state, location, date, time, brand_id, active, address, zip, cutoff_time") .select("city, state, location, date, time, brand_id, active, address, zip, cutoff_time")
.eq("id", duplicate) .eq("id", duplicate)
.single(); .single()) as { data: Stop | null };
duplicateFrom = data; duplicateFrom = data;
} }
@@ -44,7 +44,7 @@ export default async function NewStopPage({
adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"; adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const [{ data: allProducts }] = await Promise.all([ const [{ data: allProducts }] = await Promise.all([
supabase api
.from("products") .from("products")
.select("id, name, type, price") .select("id, name, type, price")
.eq("brand_id", brandId) .eq("brand_id", brandId)
+3 -3
View File
@@ -1,6 +1,6 @@
import StopTableClient from "@/components/admin/StopTableClient"; import StopTableClient from "@/components/admin/StopTableClient";
import StopsHeaderActions from "@/components/admin/StopsHeaderActions"; import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
import { supabase } from "@/lib/supabase"; import { api } from "@/lib/api";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system"; import { PageHeader } from "@/components/admin/design-system";
@@ -22,7 +22,7 @@ export default async function AdminStopsPage() {
redirect("/admin/pickup"); redirect("/admin/pickup");
} }
let query = supabase let query = api
.from("stops") .from("stops")
.select(` .select(`
id, id,
@@ -49,7 +49,7 @@ export default async function AdminStopsPage() {
query = query.eq("brand_id", adminUser.brand_id); query = query.eq("brand_id", adminUser.brand_id);
} }
const { data: stops, error } = await query; const { data: stops, error } = (await query) as any;
if (error) { if (error) {
return ( return (
+3 -3
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { supabase } from "@/lib/supabase"; import { api } from "@/lib/api";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import TaxDashboard from "@/components/admin/TaxDashboard"; import TaxDashboard from "@/components/admin/TaxDashboard";
import { PageHeader } from "@/components/admin/design-system"; import { PageHeader } from "@/components/admin/design-system";
@@ -20,7 +20,7 @@ export default async function TaxesPage({ params }: Props) {
let resolvedBrandId = effectiveBrandId; let resolvedBrandId = effectiveBrandId;
if (isPlatformAdmin && !resolvedBrandId) { if (isPlatformAdmin && !resolvedBrandId) {
const { data: firstBrand } = await supabase const { data: firstBrand } = await api
.from("brands") .from("brands")
.select("id") .select("id")
.limit(1) .limit(1)
@@ -54,7 +54,7 @@ export default async function TaxesPage({ params }: Props) {
if (!resolvedBrandId) return <AdminAccessDenied />; if (!resolvedBrandId) return <AdminAccessDenied />;
const { data: brands } = await supabase const { data: brands } = await api
.from("brands") .from("brands")
.select("id, name") .select("id, name")
.order("name"); .order("name");
+2 -2
View File
@@ -82,8 +82,8 @@ Use "recent_orders" for recent order questions.`;
const queryType = parsed.queryType ?? "recent_orders"; const queryType = parsed.queryType ?? "recent_orders";
const days = parsed.days ?? 30; const days = parsed.days ?? 30;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const supabaseKey = process.env.POSTGREST_SERVICE_KEY;
// Route to appropriate RPC based on query type // Route to appropriate RPC based on query type
let rpcName = "get_recent_orders_insights"; let rpcName = "get_recent_orders_insights";
+4
View File
@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);
+2 -2
View File
@@ -2,8 +2,8 @@ import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
export async function GET() { export async function GET() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const supabaseKey = process.env.POSTGREST_SERVICE_KEY;
if (!supabaseUrl || !supabaseKey) { if (!supabaseUrl || !supabaseKey) {
return NextResponse.json({ error: "Missing configuration" }, { status: 500 }); return NextResponse.json({ error: "Missing configuration" }, { status: 500 });
-45
View File
@@ -1,45 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? "";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
if (!serviceKey || !supabaseUrl) {
return NextResponse.json({ error: "Missing env vars", serviceKey: !!serviceKey, supabaseUrl: !!supabaseUrl }, { status: 500 });
}
// Test 1: just a simple health endpoint that doesn't require the key
let healthResult = null;
try {
const res = await fetch(`${supabaseUrl}/rest/v1/`, {
headers: { apikey: serviceKey },
});
healthResult = { status: res.status, ok: res.ok };
} catch (e: any) {
healthResult = { error: e?.message };
}
// Test 2: try admin_users with POST (bypasses RLS select policies)
let adminResult = null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?select=id,user_id,role,email,display_name&limit=5`,
{
method: "GET",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
}
);
const body = await res.text();
let parsed = null;
try { parsed = JSON.parse(body); } catch { parsed = body; }
adminResult = { status: res.status, body: parsed };
} catch (e: any) {
adminResult = { error: e?.message };
}
return NextResponse.json({ healthResult, adminResult, serviceKeyLen: serviceKey.length });
}
-48
View File
@@ -1,48 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
const rcUid = cookieStore.get("rc_uid")?.value;
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
let adminUsersStatus = "not_tried";
let adminUsersResult: string | null = null;
if (rcAuthUid) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (supabaseUrl && serviceKey) {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
adminUsersStatus = String(res.status);
const data = await res.json().catch(() => null);
adminUsersResult = JSON.stringify(data);
} catch (e) {
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
}
} else {
adminUsersStatus = "missing_env_vars";
}
} else {
adminUsersStatus = "no_rc_auth_uid_cookie";
}
return NextResponse.json({
cookies: {
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 10)}...` : null,
rc_uid: rcUid ? `${rcUid.slice(0, 10)}...` : null,
rc_access_token: rcAccessToken ? "present" : null,
all_cookie_names: allCookies.map(c => c.name),
},
admin_users: {
status: adminUsersStatus,
result: adminUsersResult,
},
});
}

Some files were not shown because too many files have changed in this diff Show More