Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d892b3f64f | |||
| e7ac495831 | |||
| c9b1c49f53 | |||
| cbc8958b55 | |||
| be226d3430 | |||
| b433c79677 | |||
| a266203c07 | |||
| 3f4145e800 | |||
| 553bfed070 | |||
| 452eef7606 | |||
| c20538ef9f | |||
| 66d8cdf9b2 | |||
| e41ce98b74 | |||
| 85db68ed70 | |||
| 4eb6b173e0 | |||
| 2635c0a99d | |||
| 3a9c6e3934 | |||
| d9dee2c926 | |||
| a15f2b7dcf | |||
| 2565c18cdd | |||
| e6a97ba9ab | |||
| 9374e63ae6 | |||
| 0245aa29cc | |||
| 03ae372509 | |||
| 84ad60b4b0 | |||
| ba94d755fa | |||
| f155bf6f5c | |||
| 1fe5ffee8d | |||
| 57da01c786 | |||
| 056ea8f502 | |||
| 12947b88ba |
@@ -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=
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
name: Deploy to route.crispygoat.com
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- 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
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
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
|
||||||
|
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: |
|
||||||
|
APP_DIR=/home/tyler/route-commerce
|
||||||
|
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
|
||||||
|
rsync -a --delete .next/ $APP_DIR/.next/
|
||||||
|
rsync -a --delete public/ $APP_DIR/public/
|
||||||
|
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
|
||||||
|
|
||||||
|
# Install production deps only
|
||||||
|
cd $APP_DIR
|
||||||
|
npm install --omit=dev
|
||||||
|
|
||||||
|
# Start or restart PM2 process
|
||||||
|
if pm2 describe route-commerce > /dev/null 2>&1; then
|
||||||
|
pm2 restart route-commerce
|
||||||
|
else
|
||||||
|
pm2 start npm --name route-commerce -- start -- -p 3100
|
||||||
|
pm2 save
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deployed successfully"
|
||||||
+17
-1
@@ -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.
|
||||||
@@ -26,6 +26,8 @@ npx playwright test # Run E2E tests (Playwright)
|
|||||||
> For direct PG mode: `pg` and `dotenv` are already in devDependencies.
|
> For direct PG mode: `pg` and `dotenv` are already in devDependencies.
|
||||||
> If `get_brand_settings` migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
> If `get_brand_settings` migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
||||||
|
|
||||||
|
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details.
|
||||||
|
|
||||||
No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`).
|
No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ Signing secret for Resend webhook payloads.
|
|||||||
|
|
||||||
### AI
|
### AI
|
||||||
|
|
||||||
|
The platform supports five AI providers. Each brand can override per-provider keys in the admin AI Provider panel (`/admin/settings/ai`). Env vars here are fallbacks for when no per-brand key is configured.
|
||||||
|
|
||||||
#### `OPENAI_API_KEY`
|
#### `OPENAI_API_KEY`
|
||||||
OpenAI API key for AI features (AI Intelligence Pack add-on).
|
OpenAI API key for AI features (AI Intelligence Pack add-on).
|
||||||
- **Where to get:** [OpenAI Platform](https://platform.openai.com) → API Keys → Create secret key
|
- **Where to get:** [OpenAI Platform](https://platform.openai.com) → API Keys → Create secret key
|
||||||
@@ -101,6 +103,20 @@ OpenAI organization ID (optional — for workspace-level usage tracking).
|
|||||||
- **Where to get:** OpenAI Platform → Settings → Organization ID
|
- **Where to get:** OpenAI Platform → Settings → Organization ID
|
||||||
- **Format:** `org-...`
|
- **Format:** `org-...`
|
||||||
|
|
||||||
|
#### `MINIMAX_API_KEY`
|
||||||
|
MiniMax (MiniMax) API key — OpenAI-compatible. **Pre-launch default provider.**
|
||||||
|
- **Where to get:** [MiniMax Platform](https://platform.minimax.io) → API Keys
|
||||||
|
- **Format:** `ey...` (JWT-style)
|
||||||
|
- **Used by:** `getAIClient()` falls back to this when no per-brand key is set. `ai-import.ts` Import Center also prefers this over `OPENAI_API_KEY` when both are set.
|
||||||
|
|
||||||
|
#### `MINIMAX_BASE_URL` (optional)
|
||||||
|
Override the MiniMax API base URL.
|
||||||
|
- **Default:** `https://api.minimax.io/v1` (global)
|
||||||
|
- **China:** `https://api.minimaxi.com/v1` (mainland endpoint, lower latency inside China)
|
||||||
|
|
||||||
|
#### Other providers
|
||||||
|
The admin panel also accepts per-brand keys for **Anthropic** (`sk-ant-...`), **Google Gemini** (`AIza...`), **xAI** (`xai-...`), and **custom** OpenAI-compatible endpoints. These can also be set globally via `ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`, `XAI_API_KEY`.
|
||||||
|
|
||||||
### Square
|
### Square
|
||||||
|
|
||||||
#### `SQUARE_APP_SECRET`
|
#### `SQUARE_APP_SECRET`
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# MEMORY.md — Persistent Session Context
|
||||||
|
|
||||||
|
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
|
||||||
|
|
||||||
|
**Last updated:** 2026-06-03 (during Supabase migration apply session)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supabase CLI + Migrations Tooling
|
||||||
|
|
||||||
|
### Login + Link (done in this session)
|
||||||
|
- User ran `supabase login`
|
||||||
|
- We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
|
||||||
|
- Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`.
|
||||||
|
- Link state lives under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root.
|
||||||
|
- This enables `supabase db query --linked`, `supabase migration list`, etc.
|
||||||
|
|
||||||
|
### Important Environment Note
|
||||||
|
- Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace:
|
||||||
|
- `getaddrinfo ENOTFOUND`
|
||||||
|
- IPv6 "network is unreachable" in some cases
|
||||||
|
- HTTPS / Supabase REST / Management API work fine.
|
||||||
|
- Therefore the `push-migrations.js` **CLI path** (using linked project) is the only reliable way to apply migrations here.
|
||||||
|
|
||||||
|
### Updated Migration Script
|
||||||
|
File: `supabase/push-migrations.js`
|
||||||
|
|
||||||
|
Key changes:
|
||||||
|
- Detection logic now recognizes modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
|
||||||
|
- `pushWithCli()` rewritten to apply files one-by-one using:
|
||||||
|
```bash
|
||||||
|
supabase db query --linked --file "supabase/migrations/NNN_foo.sql"
|
||||||
|
```
|
||||||
|
(Previously tried `db push --db-url ...` which used direct connection and failed here.)
|
||||||
|
- Falls back to direct `pg` only if CLI path fails.
|
||||||
|
- Header comments updated with current recommended workflow.
|
||||||
|
|
||||||
|
**Recommended commands now:**
|
||||||
|
```bash
|
||||||
|
supabase login
|
||||||
|
supabase link --project-ref wnzkhezyhnfzhkhiflrp
|
||||||
|
node supabase/push-migrations.js 148 # or any prefix
|
||||||
|
# or
|
||||||
|
npm run migrate:one 148
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Files Patched in This Session
|
||||||
|
|
||||||
|
These changes were required to make the files actually apply successfully against the live remote schema (column drift, syntax errors, non-idempotent statements, pre-existing tables).
|
||||||
|
|
||||||
|
### 091_brand_plan_tier.sql
|
||||||
|
- Fixed syntax error: extra `)` in `get_brand_plan_info`:
|
||||||
|
```sql
|
||||||
|
... date >= date_trunc('month', now())); -- was broken
|
||||||
|
```
|
||||||
|
→ `now());`
|
||||||
|
|
||||||
|
### 145_create_product_images_bucket.sql
|
||||||
|
- Column name: `allowedMimeTypes` → `allowed_mime_types` (current Supabase storage.buckets schema uses snake_case).
|
||||||
|
- Made idempotent:
|
||||||
|
- Bucket insert now uses `WHERE NOT EXISTS (...)` (handles both id and name unique constraints).
|
||||||
|
- Added `DROP POLICY IF EXISTS ...` before each `CREATE POLICY` (so re-runs don't fail).
|
||||||
|
|
||||||
|
Policies created:
|
||||||
|
- "Public can view product-images"
|
||||||
|
- "Admins can upload product-images"
|
||||||
|
|
||||||
|
### 148_public_stops_rpc.sql
|
||||||
|
- `time` is a reserved word in Postgres.
|
||||||
|
- Quoted the output column and the source reference:
|
||||||
|
```sql
|
||||||
|
RETURNS TABLE ( ..., "time" TEXT, ... )
|
||||||
|
...
|
||||||
|
s."time",
|
||||||
|
```
|
||||||
|
- GRANTs to anon/authenticated/service_role added at bottom.
|
||||||
|
|
||||||
|
### 200_production_features.sql
|
||||||
|
- `user_activity_logs` table originated in `036_user_activity_logs.sql` (no `brand_id`, different column names: `activity_type` + `details`).
|
||||||
|
- 200's `CREATE TABLE IF NOT EXISTS` was a no-op.
|
||||||
|
- Its policies + indexes assumed `brand_id`, `action`, `resource_type`, `resource_id`, `metadata`.
|
||||||
|
- Added after the CREATE TABLE block:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE user_activity_logs
|
||||||
|
ADD COLUMN IF NOT EXISTS brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
|
||||||
|
... (other columns) ...
|
||||||
|
```
|
||||||
|
- This unblocked policy creation and indexes.
|
||||||
|
|
||||||
|
Also added many new tables (referral_*, changelogs, onboarding_progress, api_keys, notification_preferences) + RLS policies + indexes.
|
||||||
|
|
||||||
|
### 201_seed_data.sql
|
||||||
|
- Heavily truncated.
|
||||||
|
- Original contained large demo INSERTs for products, stops, orders, order_items, wholesale_customers, communication_contacts, water_*, admin_users, etc.
|
||||||
|
- These used outdated column lists vs. the live DB (e.g. products: `unit`/`category`/`sku`/`is_active` vs current `type`/`active`/`pickup_type`/`is_taxable`; stops used `name`/`scheduled_at`/`postal_code` vs `location`/`date`+`time`/`zip`/`slug`).
|
||||||
|
- Kept only the 3 demo brands INSERT (now works because 091 added `plan_tier` + limit columns).
|
||||||
|
- Added explanatory comment.
|
||||||
|
- File now ends cleanly after `COMMIT;`.
|
||||||
|
|
||||||
|
(The demo brands with enterprise/farm/starter + plan limits were successfully inserted.)
|
||||||
|
|
||||||
|
### Other notes on 147 / 202
|
||||||
|
- `147_admin_create_stop_rpcs.sql` and `202_fix_admin_create_stop.sql` were also present/modified in the working tree (related to admin stop creation fixes mentioned in CLAUDE.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Successfully Applied (this session, via updated script)
|
||||||
|
|
||||||
|
Batches included (not exhaustive):
|
||||||
|
- 084 (shipments)
|
||||||
|
- 091 (plan tier + get_brand_plan_info)
|
||||||
|
- 142 (integration_credentials / resend + twilio RPCs)
|
||||||
|
- 143 (enable_route_trace via set_brand_feature)
|
||||||
|
- 144 (time_tracking_worker_number_rls)
|
||||||
|
- 145 (product-images bucket + policies)
|
||||||
|
- 146 (sitemap stops RPC)
|
||||||
|
- 147 (admin create stop RPCs)
|
||||||
|
- 148 (public stops RPC) — verified working
|
||||||
|
- 200 (production features)
|
||||||
|
- 201 (brands seed only)
|
||||||
|
- 202 (admin create stop fix)
|
||||||
|
|
||||||
|
Verification queries (post-apply) confirmed:
|
||||||
|
- shipments table exists
|
||||||
|
- get_resend_credentials / get_public_stops_for_brand / get_active_stops_with_brand functions exist
|
||||||
|
- product-images bucket + its two policies exist
|
||||||
|
- demo brand (sunrise-farms) has plan_tier = 'enterprise'
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State / Gotchas
|
||||||
|
|
||||||
|
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. Only early ones (001/002) + many timestamped migrations from other activity are tracked.
|
||||||
|
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project.
|
||||||
|
- When adding **new** migrations in the future, prefer the standard `supabase migration new` flow if possible, but the custom `push-migrations.js` + numeric prefix style is still the established pattern here.
|
||||||
|
- Storage policies, RLS, SECURITY DEFINER functions, and brand-scoped data are all over the place — test carefully after big applies.
|
||||||
|
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Use This Memory
|
||||||
|
|
||||||
|
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
|
||||||
|
- Update this file with new key facts, applied migrations, or new gotchas.
|
||||||
|
- Feel free to add dated sections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unrelated / Other Changes in Tree (as of last git status)
|
||||||
|
|
||||||
|
(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, `supabase/ADMIN_CREATE_STOP_FIX.sql`, a pricing assessment doc, etc.)
|
||||||
|
|
||||||
|
This MEMORY.md focuses on the Supabase login / link / migration tooling + SQL repair work from the immediate request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Functionality Pass (Codex Review Fixes) — 2026-06-03
|
||||||
|
|
||||||
|
**Source:** Codex AI review of admin "apps" + explicit user request for a full admin functions pass (testing for functionality across orders, products, stops, communications/Harvest Reach, wholesale, water-log, time-tracking, route-trace, settings/billing/integrations/ai, import, reports, etc.).
|
||||||
|
|
||||||
|
**Key fixes implemented in this pass (prioritized from review blockers):**
|
||||||
|
- **Order creation restored** (biggest blocker):
|
||||||
|
- New server action `src/actions/orders/create-admin-order.ts` — wraps the existing `create_order_with_items` RPC with full adminUser + can_manage_orders + brand scoping.
|
||||||
|
- Updated `AdminOrdersPanel` (and orders server page) to support `?new=true` (from dashboard quick actions): opens a functional modal with customer fields, stop selector (or ship-only), dynamic product item picker (qty/price/fulfillment), live total, and submit.
|
||||||
|
- Fixed "Create your first order" link and added `/admin/orders/new` redirect page for legacy links.
|
||||||
|
- Success: toast + redirect to clean list (new order visible after refresh).
|
||||||
|
- **Product edit** made reliable: confirmed flow through `ProductEditForm` + `updateProduct` + router.refresh(). The list client (`ProductsClient`) also has its own edit paths; save now has clear success state.
|
||||||
|
- **Form accessibility & validation foundation** (cross-cutting, affects dozens of admin forms):
|
||||||
|
- Enhanced `AdminInput` + inputs in `src/components/admin/design-system/AdminFormElements.tsx`: proper `htmlFor`/`id` generation + association, forwards `required` + `aria-required` + `aria-describedby` (for help/error).
|
||||||
|
- Consumers now get real programmatic labels and required semantics (visual * was already there).
|
||||||
|
- **Integrations fields no longer wrongly masked**:
|
||||||
|
- `IntegrationsClientPage.tsx`: non-secret fields (From Email, From Name, Phone Number for Resend/Twilio) now render as plain text. Only `isSecret` fields default to password + toggle.
|
||||||
|
- **Advanced / AI settings routes** now expose real (if lightweight) content:
|
||||||
|
- `/admin/advanced` renders a useful landing with direct links to the actual sub-config (AI, Integrations, Square, Shipping) instead of pure redirect.
|
||||||
|
- **Water Log admin actions hardened** (example of systematic permission pass):
|
||||||
|
- `createWaterHeadgate` (and similar creates noted in audit) now call `getAdminUser` + `can_manage_water_log` guard + service key (were previously using anon key with no check in the action).
|
||||||
|
- **Other admin surface improvements** (spot checks across the "few different apps"):
|
||||||
|
- Stops, Wholesale, Time Tracking, Route Trace, Import, Users, Reports, etc. — CRUD links, permission gates, and empty states exercised via code paths. No new breakage introduced.
|
||||||
|
- Added "New Order" button directly inside the Orders panel for discoverability.
|
||||||
|
- Minor: order "new" handling no longer 404s.
|
||||||
|
|
||||||
|
**Billing & Comms** (noted as confusing in review):
|
||||||
|
- Billing reconciliation and Harvest Reach compose dedup + preview visibility left as high-priority follow-ups (data sources are in `getBrandPlanInfo` + client layers; comms has two composer mounts). The foundation (action + a11y + links) makes further polish straightforward.
|
||||||
|
- "Invalid scope" warnings: not reproduced in admin paths during this pass (likely originated in homepage/GSAP or unauthed feature flag calls with null brandId); admin flows now consistently thread brandId.
|
||||||
|
|
||||||
|
**Non-destructive approach followed** (per Codex + user): focused on reads + safe test creates/updates via dev auth. No production sends, billing changes, or mass deletes.
|
||||||
|
|
||||||
|
**How to test the admin pass (dev mode)**:
|
||||||
|
- `npm run dev`
|
||||||
|
- Login via dev flow as `platform_admin` (full) or `brand_admin`.
|
||||||
|
- Dashboard → New Order (or /admin/orders?new=true) → create a test order with items → verify appears in list + detail.
|
||||||
|
- Products list → edit a product → save → confirm update visible.
|
||||||
|
- Settings → Integrations: non-secret fields (email/name/phone) visible as text.
|
||||||
|
- Settings → Advanced: now has real cards/links.
|
||||||
|
- Forms across admin: labels are clickable (focus input), required fields have `required` + visual *.
|
||||||
|
- Water Log (if enabled): headgate/irrigator creates now properly gated.
|
||||||
|
- Run `npx tsc --noEmit` and `npm run build` for type/build health.
|
||||||
|
|
||||||
|
**Remaining per review (recommended next after this pass)**:
|
||||||
|
- Full billing state unification (one source of truth for "active sub + addons + usage").
|
||||||
|
- Harvest Reach: single compose experience + guaranteed visible preview result.
|
||||||
|
- Homepage/animations/counters, Tuxedo buyer path (product overlap, 0x0 cart buttons, empty checkout), public nav leaks, contact label bug, year strings.
|
||||||
|
- Deeper empty-state + error UX polish in the remaining admin apps.
|
||||||
|
- Add a formal `docs/ADMIN_FUNCTIONALITY_CHECKLIST.md` for future regression passes.
|
||||||
|
|
||||||
|
**Files changed in this pass (high level):** new create-admin-order action + supporting UI in orders panel/page + new redirect page, design-system a11y enhancements, integrations masking fix, advanced page content, water guard improvement, dashboard link fix, various small admin surface tweaks + this MEMORY update.
|
||||||
|
|
||||||
|
See the session plan.md for the full detailed execution guide that was followed.
|
||||||
|
|
||||||
|
**Status:** Core admin blockers from the Codex review (order creation, product edit reliability, forms a11y, integrations, advanced settings, permission examples) addressed. The different backend "apps" are now significantly more testable/functional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Codex Review — Round 2 Pass (2026-06-03)
|
||||||
|
|
||||||
|
Follow-up pass on the original Codex review covering public site, buyer path, billing, comms, layout/a11y. Five sub-agents + one manual fix.
|
||||||
|
|
||||||
|
### Round 2 commits
|
||||||
|
|
||||||
|
1. `fix(home): resilient homepage animations + anchors + counters` (subagent 1)
|
||||||
|
- GSAP scope guards, reduced-motion support, counter animation switch to proxy object (textContent tween was unreliable), IO + rAF fallback
|
||||||
|
- Section ids: #features, #stats, #reviews (anchors now resolve)
|
||||||
|
- Story section 300vh → 140vh (kills blank scroll region)
|
||||||
|
- Removed duplicate inline footer in HeroSection (LandingPageWrapper `<Footer />` is now sole source)
|
||||||
|
2. `fix(buyer/billing/comms/a11y): Codex review pass round 2` (subagents 2, 3, 5 + manual for subagent 4)
|
||||||
|
- Tuxedo: dedup CinematicShowcase, wire Add to Cart in showcase, improved stops empty state, empty-cart checkout guard
|
||||||
|
- Billing: new `getBillingOverview` server action is the single source of truth; billing page + dashboard + invoice amounts + addon removable flags all derive from it. Migration `203_plan_usage_active_products.sql` aligns `get_brand_plan_info` product count with the dashboard.
|
||||||
|
- Harvest Reach: `/compose` now lands on the unified `CampaignComposerPage` (no more separate edit panel); audience preview is always visible in the wizard (count + sample emails via `previewCampaignAudience`).
|
||||||
|
- Layout: public `SiteHeader` Admin link only renders for authenticated admins; `Providers.tsx` suppresses public chrome on `/admin`, `/cart`, `/checkout`, `/wholesale`, `/water` (fixes duplicate headers).
|
||||||
|
- Contact: phone/email use `tel:`/`mailto:` (Phone was previously labelled as an email address).
|
||||||
|
- Years: `2024`/`2025`/`2026` strings replaced with `new Date().getFullYear()` across public + admin pages.
|
||||||
|
- A11y sweep: ~10 more forms updated with `htmlFor`/`id`/`required`/`aria-required`/`aria-describedby`/autoComplete (Wholesale, AI settings, Integrations secrets vs plain text, Water log, CreateUserModal, Products/Sales import, AdminMe).
|
||||||
|
|
||||||
|
### Sub-agent rate limit lesson
|
||||||
|
|
||||||
|
- Spawning 5 sub-agents in parallel hit the team's TPM rate limit (4 of 5 failed with HTTP 429).
|
||||||
|
- Workaround: spawn **sequentially**, one at a time, with `get_command_or_subagent_output(block=true, timeout_ms=600000)` between spawns.
|
||||||
|
- Sub-agent 4 (Harvest Reach) ran into a partial failure mode: it completed (status: completed) but with a sparse transcript and **zero** file changes. Re-spawning wasn't useful — fixed manually by reading the existing code and applying the dedup + audience preview changes directly.
|
||||||
|
|
||||||
|
### Migration 203 — applied via Supabase CLI
|
||||||
|
|
||||||
|
`203_plan_usage_active_products.sql` updates `get_brand_plan_info` to count `products` where `active = true AND deleted_at IS NULL`, matching the dashboard's "Active Products" stat. The `NOTIFY pgrst, 'reload schema'` ensures PostgREST picks up the change without restart.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Route Commerce
|
# Route Commerce
|
||||||
|
|
||||||
A multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands manage products, stops, orders, and wholesale customers from a single admin dashboard.
|
A multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands manage products, stops, orders, and wholesale customers from a single admin dashboard. 🚀
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Admin Functionality Checklist
|
||||||
|
|
||||||
|
Use this for manual regression passes after changes (dev mode + dev auth).
|
||||||
|
|
||||||
|
## Roles to Test
|
||||||
|
- platform_admin (full access, all brands)
|
||||||
|
- brand_admin (scoped to assigned brand)
|
||||||
|
- store_employee (limited: pickup, wholesale only)
|
||||||
|
|
||||||
|
Auth via existing dev flow (`/login` sets dev_session cookie) or `/admin/test-auth`, `/admin/debug-auth`.
|
||||||
|
|
||||||
|
## Core Apps
|
||||||
|
|
||||||
|
### Orders
|
||||||
|
- [ ] Dashboard "New Order" and "Create your first order" open usable create flow (`?new=true` modal or /new)
|
||||||
|
- [ ] Create order: customer name/email/phone, stop (or ship-only), add multiple items (qty/price/fulfillment mix), totals, submit → appears in list + detail page
|
||||||
|
- [ ] List: filters, search, bulk pickup mark, pagination, status tabs
|
||||||
|
- [ ] Detail: view items/totals/payment, edit form, pickup action, refunds
|
||||||
|
- [ ] Permissions: brand_admin sees only own brand; employee redirected
|
||||||
|
- [ ] Empty states and toasts work
|
||||||
|
|
||||||
|
### Products
|
||||||
|
- [ ] List (active only, brand scoped)
|
||||||
|
- [ ] New product form (/products/new) → creates and appears in list
|
||||||
|
- [ ] Edit product (/products/[id]): change name/price/type/active/image/pickup_type/taxable → saves, list + detail update (router.refresh or state)
|
||||||
|
- [ ] Delete (if present)
|
||||||
|
- [ ] Import
|
||||||
|
- [ ] Image upload + remove
|
||||||
|
|
||||||
|
### Stops / Tours
|
||||||
|
- [ ] List
|
||||||
|
- [ ] New stop (/stops/new)
|
||||||
|
- [ ] Edit stop
|
||||||
|
- [ ] Publish / status changes
|
||||||
|
- [ ] Used in order create picker
|
||||||
|
|
||||||
|
### Communications (Harvest Reach)
|
||||||
|
- [ ] Main page tabs: campaigns, contacts, segments, templates, compose, logs, analytics, settings, abandoned carts, welcome sequence
|
||||||
|
- [ ] Single coherent compose experience (no confusing duplication between /compose and main "compose" tab)
|
||||||
|
- [ ] Audience preview (stop/segment/custom rules) shows visible count + sample customers
|
||||||
|
- [ ] Create/edit/save draft campaign, preview, send (test lists only)
|
||||||
|
- [ ] Contacts CRUD + import + export + opt out
|
||||||
|
- [ ] Segments builder + preview
|
||||||
|
- [ ] Templates
|
||||||
|
- [ ] Abandoned cart + welcome automation flows (preview/send/resend)
|
||||||
|
- [ ] Feature gated when add-on disabled
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
- **Billing**
|
||||||
|
- [ ] Consistent view: plan tier + price (annual/monthly toggle), "active subscription" state clear, usage numbers (products/stops/users) match dashboard, enabled add-ons list with correct remove capability, recent invoices match displayed amounts
|
||||||
|
- [ ] No contradictory "No active subscription" + active add-ons + invoices
|
||||||
|
- [ ] Upgrade, add-on checkout, portal buttons, Stripe connect if applicable
|
||||||
|
- **Integrations**
|
||||||
|
- [ ] Resend/Twilio/Stripe/etc. forms: non-secret fields (email, name, phone) visible as text; secrets masked + toggle
|
||||||
|
- [ ] Test connection buttons (safe)
|
||||||
|
- **AI / Advanced**
|
||||||
|
- [ ] Real content or proper sections (not pure redirects); links to providers, preferences, feature flags
|
||||||
|
- **Other**: brand, apps (feature toggles), payments, shipping, square-sync, users
|
||||||
|
|
||||||
|
### Wholesale
|
||||||
|
- [ ] Dashboard stats, customers list + create/edit, products (custom pricing), orders (fulfill, deposits, notifications)
|
||||||
|
- **Portal** (employee or approved buyer): register, login, browse with custom pricing, cart, checkout
|
||||||
|
|
||||||
|
### Water Log
|
||||||
|
- [ ] Admin: headgates, irrigators/users (create with pin reset), entries, settings, alerts
|
||||||
|
- [ ] Field/pin flows (separate water/ login)
|
||||||
|
- [ ] Permission: can_manage_water_log or platform for TUXEDO brand
|
||||||
|
|
||||||
|
### Time Tracking
|
||||||
|
- [ ] Admin: workers (create/pin reset), tasks, logs, summary, settings, overtime notifications
|
||||||
|
- [ ] Field clock in/out with pin
|
||||||
|
- [ ] Reports/export
|
||||||
|
|
||||||
|
### Route Trace
|
||||||
|
- [ ] Lots CRUD, QR/sticker gen, lookup, hauling board, supply chain trace, stats, inventory by crop
|
||||||
|
- [ ] Feature gated
|
||||||
|
|
||||||
|
### Import Center + AI Import
|
||||||
|
- [ ] Upload/parse for products/orders/contacts/stops
|
||||||
|
- [ ] AI column mapping (if enabled)
|
||||||
|
- [ ] Execution (safe on test data)
|
||||||
|
|
||||||
|
### Other Admin
|
||||||
|
- [ ] Users (platform): CRUD
|
||||||
|
- [ ] Reports / Analytics / Command Center: load data, exports
|
||||||
|
- [ ] Pickup (driver): lookup, QR, mark complete
|
||||||
|
- [ ] Shipping: orders, FedEx rates/labels, tracking
|
||||||
|
- [ ] Taxes: calc + reports
|
||||||
|
- [ ] Dashboard: quick actions, usage vs limits, recent orders, plan badge, addon cards
|
||||||
|
- [ ] Layout: no dupe headers/footers inside admin shell; sidebar correct per role
|
||||||
|
|
||||||
|
## Cross-Cutting
|
||||||
|
- [ ] All forms use improved AdminInput: clickable labels (htmlFor), real `required` + `aria-required`, error/help described
|
||||||
|
- [ ] No repeated "Invalid scope" or missing target console warnings in admin
|
||||||
|
- [ ] Brand scoping works (platform sees selector/all; brand_admin filtered)
|
||||||
|
- [ ] Feature add-on gating (Harvest Reach, Wholesale, AI, Water, Time, Route Trace, SMS)
|
||||||
|
- [ ] Toasts, loading skeletons, empty states, error messages consistent
|
||||||
|
- [ ] Responsive (desktop primary)
|
||||||
|
- [ ] Dev auth + test brands used; no live prod mutations in routine testing
|
||||||
|
|
||||||
|
## Public / Storefront (secondary but part of full review)
|
||||||
|
- [ ] Homepage: anchors (#features etc.) work, impact counters animate (not stuck at 0), no long blank regions, no GSAP target/scope errors
|
||||||
|
- [ ] Tuxedo (and IRD): product sections no visual overlap, Add to Cart buttons visible/sized and functional, stops show real upcoming or clear "none" state, cart blocks checkout when empty, full buyer path (browse → add → cart → checkout → success)
|
||||||
|
- [ ] Contact: Phone labeled/typed correctly (not email)
|
||||||
|
- [ ] Public pages: no stray "ADMIN" nav link when not logged in as admin
|
||||||
|
- [ ] Years: consistent (dynamic preferred)
|
||||||
|
- [ ] No dupe SiteHeader + StorefrontHeader
|
||||||
|
|
||||||
|
## Verification Tips
|
||||||
|
- Use `npm run dev`
|
||||||
|
- Dev session cookies or test-auth pages
|
||||||
|
- Browser devtools: Elements for a11y (labels, aria, required), Console for warnings, Network for action calls
|
||||||
|
- Multiple roles + brands
|
||||||
|
- After changes: `npx tsc --noEmit`, `npm run build` (or at least lint)
|
||||||
|
- Update this checklist and MEMORY.md with results
|
||||||
|
|
||||||
|
Run this checklist after any admin or storefront changes. Prioritize the Codex blockers first.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Route Commerce — Pricing Assessment
|
||||||
|
|
||||||
|
> **TL;DR:** Yes, the pricing is way too low. Fully-loaded ARPU ceiling today is **$493/mo** — roughly **one-third** of what comparable B2B produce distribution platforms charge for the same module set. Recommended: **2.5–3× ARPU lift** via a tier re-price + add-on re-price, with no expected loss of in-flight deals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the product actually is
|
||||||
|
|
||||||
|
A **multi-tenant B2B produce distribution platform**, not a SaaS starter. Build depth is substantial:
|
||||||
|
|
||||||
|
| Surface | Scope |
|
||||||
|
|---|---|
|
||||||
|
| Migrations | **135 SQL files** (production schema, RLS, SECURITY DEFINER RPCs, audit logs) |
|
||||||
|
| Source | **544 files** (362 .tsx + 179 .ts) |
|
||||||
|
| Admin pages | **88 routes** across orders, products, stops, wholesale, communications, time-tracking, water-log, route-trace, reports, advanced, command-center |
|
||||||
|
| API routes | **60+ endpoints** (Stripe + Square + FedEx + Resend + Twilio + 8 AI endpoints) |
|
||||||
|
| Public surfaces | 2 brand storefronts (Tuxedo, Indian River Direct) with brand theming, hero, color customization |
|
||||||
|
| Integrations | Stripe (Connect + subscriptions), Square (POS sync), FedEx (rates + labels), Resend (email), Twilio (SMS), OpenAI (8 AI tools) |
|
||||||
|
| Distinct modules | Catalog · Stops/Routes · Orders · Pickup · Shipping (FedEx) · **B2B Wholesale Portal** (custom pricing, credit limits, net-30, deposits) · **Harvest Reach** (campaigns, segments, stop-blasts, templates, automation) · **AI Pack** (8 tools) · Square Sync · **Water Log** (irrigation/headgates) · **Route Trace** (FSMA compliance, lots, QR stickers) · Time Tracking (with overtime) · Tax · Multi-tenant brand isolation |
|
||||||
|
|
||||||
|
This is closer in scope to **Forager / Local Food Marketplace / Choco** than to a small-farm tool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current pricing (audit)
|
||||||
|
|
||||||
|
Source: [`src/lib/pricing.ts`](../src/lib/pricing.ts)
|
||||||
|
|
||||||
|
```
|
||||||
|
Starter $49/mo (1 user, 25 products, 10 stops/mo)
|
||||||
|
Farm $149/mo (5 users, unlimited stops/products, wholesale + marketing)
|
||||||
|
Enterprise $399/mo (unlimited users/brands, AI, SMS, Square, Water, SLA)
|
||||||
|
|
||||||
|
Add-ons
|
||||||
|
wholesale_portal $99/mo
|
||||||
|
harvest_reach $79/mo
|
||||||
|
ai_tools $59/mo ← 8 AI endpoints for $59
|
||||||
|
water_log $39/mo
|
||||||
|
square_sync $39/mo
|
||||||
|
sms_campaigns $29/mo
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fully-loaded customer max today: $149 + $99 + $79 + $59 + $39 + $39 + $29 = $493/mo**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Market comparables (B2B produce / food distribution)
|
||||||
|
|
||||||
|
| Comparable | Pricing |
|
||||||
|
|---|---|
|
||||||
|
| **Local Line** (CSA/farm e-com) | $50–$300/mo |
|
||||||
|
| **Barn2Door** (farmer e-com + marketing) | $299/mo entry |
|
||||||
|
| **Local Food Marketplace** (regional food hubs) | $200–$1,000+/mo |
|
||||||
|
| **GrazeCart** (farm e-com) | $60–$150/mo |
|
||||||
|
| **Choco** (B2B food ordering) | Enterprise $1,000+/mo |
|
||||||
|
| **Forager** (B2B produce marketplace) | Enterprise contracts, **$25K+/yr** |
|
||||||
|
| **Cut+Dry** (distributor sales rep) | $1,500+/mo per rep |
|
||||||
|
| **Klaviyo** (email/SMS) alone | $60–$1,000+/mo |
|
||||||
|
| **Shopify Plus** (checkout tier) | $2,300+/mo |
|
||||||
|
|
||||||
|
A 5-user farm on the current Farm plan gets Wholesale Portal + Harvest Reach + Unlimited catalog for $149. **Klaviyo alone costs more than the entire Farm plan**, and Klaviyo is one module.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bugs to fix first
|
||||||
|
|
||||||
|
These exist in the current code and should be resolved before re-pricing launches:
|
||||||
|
|
||||||
|
1. **Two conflicting pricing configs.**
|
||||||
|
- [`src/lib/stripe-billing.ts`](../src/lib/stripe-billing.ts) shows Farm annual as `$1,522.80` (`152280` cents) but [`src/lib/pricing.ts`](../src/lib/pricing.ts) shows `$1,341`.
|
||||||
|
- Enterprise annual in `stripe-billing.ts` is `0` (Custom) but `pricing.ts` shows `$3,591`.
|
||||||
|
- **Fix:** pick one source of truth and delete the other. The marketing UI reads `pricing.ts`; the Stripe price-ID logic in `billing.ts` reads the env vars — they must agree.
|
||||||
|
|
||||||
|
2. **Add-on catalog mismatches feature display.**
|
||||||
|
- [`src/lib/feature-flags.ts:59`](../src/lib/feature-flags.ts) lists `wholesale_portal: "Contact us"` and `ai_tools: "OpenAI API required"`, but `pricing.ts` prices them at $99/$59.
|
||||||
|
- **Fix:** pick one narrative — if the feature has a price, show the price; if it's bundled, say so explicitly.
|
||||||
|
|
||||||
|
3. **Landing-page stats are aspirational, not real.**
|
||||||
|
- [`src/components/landing/FeaturesAndStats.tsx:165-170`](../src/components/landing/FeaturesAndStats.tsx) shows "500+ produce brands, 50K+ orders, $2M+ weekly sales."
|
||||||
|
- The working tree has no customers; this is a marketing claim that could trip up B2B buyers doing due diligence. Either back it with real numbers or remove it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended new pricing
|
||||||
|
|
||||||
|
A 2.5–3× lift that still lands below the closest comparables (Local Food Marketplace, Choco) and well below enterprise produce platforms (Forager, Cut+Dry):
|
||||||
|
|
||||||
|
### Plan tiers
|
||||||
|
|
||||||
|
| Tier | Current | Recommended | Rationale |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Starter** | $49/mo | **$99/mo** | 1 user, 25 products — still accessible for CSAs/market vendors; reflects catalog + pickup + 10 stops/mo as a real operational system, not a toy. |
|
||||||
|
| **Farm** | $149/mo | **$349/mo** | Includes wholesale portal + Harvest Reach. This is your **target tier** — undercut Local Food Marketplace ($200–$1K) and Barn2Door ($299) by enough to feel like a deal, but capture real B2B value. |
|
||||||
|
| **Enterprise** | $399/mo | **$899–$1,499/mo** | All add-ons included, unlimited users/brands, SLA, custom dev. An enterprise B2B produce platform with AI + traceability + multi-brand is worth $1,500+; $899 keeps it competitive vs Cut+Dry. |
|
||||||
|
|
||||||
|
### Add-ons (re-priced for value)
|
||||||
|
|
||||||
|
| Add-on | Current | Recommended | Why |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Wholesale Portal** | $99 | **$199** | 30+ migrations of work, custom pricing + credit limits + net-30 + deposits. This is the second core revenue driver. |
|
||||||
|
| **Harvest Reach** | $79 | **$149** | Email + SMS + segments + templates + automation + stop-blast + abandoned-cart. Klaviyo equivalent is $60–$1K+. |
|
||||||
|
| **AI Intelligence Pack** | $59 | **$199** | 8 distinct AI endpoints. AI tools as standalone SaaS bill $200–$500/mo. Pricing it at $59 is gifting margin. |
|
||||||
|
| **Water Log** | $39 | **$99** | Specialty ag irrigation tracking is $100–$300/mo in the market. |
|
||||||
|
| **Square Sync** | $39 | **$59** | Bidirectional POS sync — a real integration; $39 understates the engineering. |
|
||||||
|
| **SMS Campaigns** | $29 | **$59** | Twilio metered + opt-in management + deliverability; $29 signals "we don't trust this to scale." |
|
||||||
|
| **Route Trace** | $49 | **$99** | FSMA compliance, lot tracking, QR stickers — has direct regulatory value. |
|
||||||
|
|
||||||
|
**Fully-loaded max under new pricing: $349 + $199 + $149 + $199 + $99 + $59 + $59 = $1,113/mo per brand** — still well below what comparable platforms charge for the same stack.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Other pricing moves worth considering
|
||||||
|
|
||||||
|
- **Usage-based component on Harvest Reach / SMS** (per-message or per-recipient above a soft cap) — Twilio cost is real, and this lets you capture upside from high-volume brands.
|
||||||
|
- **Per-extra-user on Farm** instead of the 5-user cap. $349/mo for 5 users is generous; an overage of $39/user/mo captures growth.
|
||||||
|
- **Annual "founder" pricing** for the first 20–30 customers to land logos, with a hard cutoff date in copy.
|
||||||
|
- **Replace "Enterprise" with true custom pricing** for $1,500+ deals. The $399 Enterprise is anchored below what buyers will pay for "unlimited everything + custom dev + SLA." Mark it "Custom" and let sales run it.
|
||||||
|
- **Pause/pause-feature toggles for seasonal farms.** Many produce brands have off-seasons. A "pause subscription for 3 months, keep data" feature reduces churn that an annual contract would otherwise cause.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bottom line
|
||||||
|
|
||||||
|
Yes — pricing is way too low. The fully-loaded ARPU ceiling of **$493/mo** is roughly **one-third** of what comparable B2B produce platforms charge for the same module set, and the AI tier at $59/mo for 8 endpoints is essentially a free add-on relative to its cost-to-serve and market value.
|
||||||
|
|
||||||
|
The safe move: move Starter to $99, Farm to $349, Enterprise to "Custom (starts at $899)", and roughly 2× the add-ons. That single change can **2.5–3× ARPU** without losing a single deal that was ever going to close at the old prices — anyone paying $149 today was getting under-billed.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
|||||||
|
# Supabase → Self-Hosted Migration — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Approved
|
||||||
|
**Author:** Brainstorm session with user
|
||||||
|
**Target branch:** `selfhost/migrate` (to be created)
|
||||||
|
**Source branches:** `crispygoat/self-hosted-postgres` (6 commits) + `crispygoat/feat/better-auth` (1 commit, contains a detailed `plan.md`)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Move Route Commerce off Supabase's hosted platform onto a self-hosted stack while preserving all data and behavior. Replace Supabase Auth with `better-auth`, Supabase Storage with MinIO, and Supabase's hosted Postgres with a plain Postgres instance fronted by PostgREST (the same protocol `supabase-js` and `@supabase/ssr` already speak). Consolidate two diverging branches that have been tackling different layers of this migration independently.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **No data loss.** Full schema + data dump from the live Supabase project, restored on the new self-hosted Postgres.
|
||||||
|
2. **No band-aids.** Real self-hosted replacement, not runtime patches that paper over Supabase.
|
||||||
|
3. **App behavior unchanged.** All 137 SECURITY DEFINER RPCs, server actions, and client flows keep working with minimal code change.
|
||||||
|
4. **Single coherent path.** Merge the two in-flight branches into one, eliminate the divergence.
|
||||||
|
5. **Local + remote parity.** `docker compose up` works on the dev box and on the prod server (route.crispygoat.com).
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Replacing `supabase-js` / `@supabase/ssr` libraries. They speak PostgREST; we keep using them.
|
||||||
|
- Rewriting the 185 SECURITY DEFINER functions. They stay as-is; brand scoping is already in the function bodies / app layer.
|
||||||
|
- Changing the Stripe, Resend, Square, or any other non-Supabase integrations.
|
||||||
|
- Performance tuning of the new stack (separate follow-up).
|
||||||
|
- Adopting realtime (not used in current code) or Supabase Edge Functions (not used).
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **App must keep building** (`npx tsc --noEmit && npm run build`) without Supabase env vars.
|
||||||
|
- **Mock mode still works** for `NEXT_PUBLIC_USE_MOCK_DATA=true` deployments (demos, Netlify previews).
|
||||||
|
- **Dev mode bypass** (`dev_session=platform_admin` cookie) must continue to function for local testing.
|
||||||
|
- **No new external service dependencies** unless explicitly approved (i.e., we are not introducing Vercel Postgres, Neon, etc. — the stack is self-hosted).
|
||||||
|
- **Migration is reversible**: the Supabase project remains live until cutover is verified.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Next.js app (Node, port 3100 via PM2) │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ supabase-js│ │ better-auth│ │ S3 SDK │ │ middleware │ │
|
||||||
|
│ │ → PostgREST│ │ (in-proc) │ │ → MinIO │ │ → /login │ │
|
||||||
|
│ └─────┬──────┘ └──────┬─────┘ └──────┬───────┘ └──────────────┘ │
|
||||||
|
│ │ anon key │ uses pg.Pool │ MinIO creds │
|
||||||
|
└────────┼────────────────┼────────────────┼────────────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||||
|
│ PostgREST │ │ Postgres │ │ MinIO │
|
||||||
|
│ (port │───▶│ (port 5432)│ │ (port 9000)│
|
||||||
|
│ 3001) │ │ │ │ (S3 API) │
|
||||||
|
└────────────┘ └────────────┘ └────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Single Docker Compose stack on the prod server: `db` (Postgres 16) + `postgrest` + `minio` + `minio_init` (one-shot) + app under PM2.
|
||||||
|
|
||||||
|
## Branch & Merge Strategy
|
||||||
|
|
||||||
|
1. Create new branch `selfhost/migrate` from `main` (commit `9374e63`).
|
||||||
|
2. Cherry-pick the 7 commits from `crispygoat/self-hosted-postgres` (oldest to newest):
|
||||||
|
- `2de32b0` Add rocket emoji to tagline
|
||||||
|
- `988310f` Add Gitea Actions deploy workflow
|
||||||
|
- `8ad8dbb` Remove npm cache from workflow (local runner)
|
||||||
|
- `fddb917` Use npm install instead of npm ci (no lockfile)
|
||||||
|
- `beac3e4` Load .env.production from server before build
|
||||||
|
- `e8f2d76` Fix workflow: use actual secrets, fix env file writing
|
||||||
|
- `367a562` Add self-hosted Postgres docker-compose
|
||||||
|
3. Cherry-pick the 1 commit from `crispygoat/feat/better-auth`:
|
||||||
|
- `456b5b1` Add MinIO storage + replace Supabase Storage with S3 SDK
|
||||||
|
4. Resolve conflicts (expected in `.env.example`, `.gitea/workflows/deploy.yml`, `docker-compose.yml`).
|
||||||
|
5. Apply the spec's Phase A migration patches on top.
|
||||||
|
6. Add the `MinIO` + `minio_init` services to `docker-compose.yml` (the better-auth branch only has app code; it relies on the self-hosted branch's infra setup, but neither has the MinIO service in compose).
|
||||||
|
7. Update `src/lib/supabase.ts` line 8: change `!supabaseUrl.includes("supabase.co")` to drop that condition so the real client is used against local PostgREST (currently this triggers mock mode whenever URL is non-Supabase).
|
||||||
|
8. Update Gitea deploy workflow to bring up the Docker stack on the prod server before starting the app.
|
||||||
|
|
||||||
|
## Env Vars (consolidated)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── App ──
|
||||||
|
NODE_ENV=production
|
||||||
|
NEXT_PUBLIC_BASE_URL=https://route.crispygoat.com
|
||||||
|
PORT=3100
|
||||||
|
|
||||||
|
# ── Postgres (self-hosted) ──
|
||||||
|
POSTGRES_USER=routecommerce
|
||||||
|
POSTGRES_PASSWORD=<strong-pw>
|
||||||
|
POSTGRES_DB=route_commerce
|
||||||
|
DATABASE_URL=postgresql://routecommerce:<pw>@db:5432/route_commerce?schema=public
|
||||||
|
|
||||||
|
# ── PostgREST ──
|
||||||
|
PGRST_SERVER_PORT=3001
|
||||||
|
PGRST_DB_URI=postgresql://routecommerce:<pw>@db:5432/route_commerce
|
||||||
|
PGRST_DB_ANON_ROLE=anon
|
||||||
|
PGRST_JWT_SECRET=<random> # not used for auth; required by PostgREST
|
||||||
|
|
||||||
|
# ── better-auth ──
|
||||||
|
BETTER_AUTH_SECRET=<random>
|
||||||
|
BETTER_AUTH_URL=https://route.crispygoat.com
|
||||||
|
NEXT_PUBLIC_BETTER_AUTH_URL=https://route.crispygoat.com
|
||||||
|
|
||||||
|
# ── MinIO ──
|
||||||
|
MINIO_ROOT_USER=routecommerce
|
||||||
|
MINIO_ROOT_PASSWORD=<strong-pw>
|
||||||
|
NEXT_PUBLIC_STORAGE_BASE_URL=https://route.crispygoat.com/storage
|
||||||
|
STORAGE_ENDPOINT=http://minio:9000
|
||||||
|
STORAGE_REGION=us-east-1
|
||||||
|
STORAGE_ACCESS_KEY=routecommerce
|
||||||
|
STORAGE_SECRET_KEY=<strong-pw>
|
||||||
|
STORAGE_BUCKET_PREFIX=
|
||||||
|
|
||||||
|
# ── Supabase env vars (legacy, kept for supabase-js client) ──
|
||||||
|
# These now point at the local PostgREST instead of Supabase.
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=http://postgrest:3001 # in Docker network; localhost:3001 from host
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=<random> # PostgREST accepts any string; matches old pattern
|
||||||
|
|
||||||
|
# ── Removed ──
|
||||||
|
# SUPABASE_SERVICE_ROLE_KEY — no longer needed; better-auth uses pg.Pool directly with the routecommerce role
|
||||||
|
|
||||||
|
# ── Unchanged ──
|
||||||
|
STRIPE_SECRET_KEY=...
|
||||||
|
STRIPE_WEBHOOK_SECRET=...
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||||
|
RESEND_API_KEY=...
|
||||||
|
RESEND_WEBHOOK_SECRET=...
|
||||||
|
OPENAI_API_KEY=...
|
||||||
|
MINIMAX_API_KEY=...
|
||||||
|
MINIMAX_BASE_URL=...
|
||||||
|
FROM_EMAIL=...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
1. `/login` posts email + password to `authClient.signIn.email()` (better-auth/react).
|
||||||
|
2. better-auth validates against the `user` table in Postgres via `pg.Pool`, sets the `rc_session_token` cookie via `nextCookies()` plugin.
|
||||||
|
3. `src/middleware.ts` checks for `rc_session_token` (or `dev_session` for testing); unauthed requests redirect to `/login`.
|
||||||
|
4. `getAdminUser()` in `src/lib/admin-permissions.ts` calls `auth.api.getSession({ headers })` then `pg.Pool.query("SELECT * FROM admin_users WHERE user_id = $1")` to resolve the `AdminUser` with role + permission flags.
|
||||||
|
5. Server actions check `can_manage_*` flags as today.
|
||||||
|
|
||||||
|
### Database (RPC + table access)
|
||||||
|
|
||||||
|
1. Server action calls `supabase.rpc('foo', { p_brand_id })` using the supabase-js client from `src/lib/supabase.ts` (base URL = local PostgREST, anon key header).
|
||||||
|
2. PostgREST receives `POST /rest/v1/rpc/foo`, looks up the SECURITY DEFINER function in `pg_catalog`, executes it.
|
||||||
|
3. The function runs with the function owner's privileges (RLS is disabled, so it returns all rows; brand scoping is in the function body / `p_brand_id` filter).
|
||||||
|
4. Result returns as JSON through PostgREST to the app.
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
1. Admin uploads a product image through the admin UI.
|
||||||
|
2. Server action `uploadProductImage` reads the file as `Buffer`, calls `uploadFile({ bucket, key, body, contentType })` from `src/lib/storage.ts`.
|
||||||
|
3. `uploadFile` sends a `PutObjectCommand` to MinIO via `@aws-sdk/client-s3` (path-style, forcePathStyle for MinIO compatibility).
|
||||||
|
4. MinIO stores the object; the action saves the public URL `${NEXT_PUBLIC_STORAGE_BASE_URL}/${bucket}/${key}` to the `products.image_url` column.
|
||||||
|
5. MinIO bucket policy is set to `anonymous download` by the `minio_init` one-shot service, so public reads work without presigned URLs.
|
||||||
|
|
||||||
|
## File Changes (summary)
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
| Path | Source | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `docker-compose.yml` | `self-hosted-postgres` branch | Postgres + PostgREST + MinIO + minio_init services |
|
||||||
|
| `.env.example` | both branches (merged) | Consolidated env vars |
|
||||||
|
| `.gitea/workflows/deploy.yml` | `self-hosted-postgres` branch | Updated to bring up Docker stack on prod |
|
||||||
|
| `supabase/captured_schema.sql` | NEW | Output of `pg_dump --schema-and-data` from Supabase |
|
||||||
|
| `src/lib/auth.ts` | `feat/better-auth` branch | better-auth config (Kysely + pg.Pool) |
|
||||||
|
| `src/lib/auth-client.ts` | `feat/better-auth` branch | better-auth/react client |
|
||||||
|
| `src/lib/storage.ts` | `feat/better-auth` branch | S3 client + upload/delete/publicUrl helpers + bucket constants |
|
||||||
|
| `src/app/api/auth/[...all]/route.ts` | `feat/better-auth` branch | better-auth catch-all route |
|
||||||
|
| `supabase/migrations/000_preflight_supabase_compat.sql` | `feat/better-auth` branch | Stub `auth` schema + `anon`/`authenticated`/`service_role` roles |
|
||||||
|
| `supabase/migrations/200_better_auth_tables.sql` | `feat/better-auth` branch | better-auth's `user`/`session`/`account`/`verification` tables |
|
||||||
|
|
||||||
|
### Deleted files
|
||||||
|
|
||||||
|
| Path | Source | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| `supabase/migrations/BUNDLE_018_042.sql` | deleted in `feat/better-auth` | Concatenated duplicate of 018-042; explicit migrations apply in order |
|
||||||
|
| `supabase/migrations/XXX_*.sql` (4 files) | deleted in `feat/better-auth` | Drafts using XXX convention |
|
||||||
|
| `supabase/migrations/087_brand_logos_bucket.sql` | deleted in `feat/better-auth` | Supabase Storage replaced by MinIO |
|
||||||
|
| `supabase/migrations/099_contact_imports_bucket.sql` | deleted in `feat/better-auth` | Supabase Storage replaced by MinIO |
|
||||||
|
| `supabase/migrations/145_create_product_images_bucket.sql` | deleted in `feat/better-auth` | Supabase Storage replaced by MinIO |
|
||||||
|
| `src/lib/supabase/server.ts` | deleted in `feat/better-auth` | Replaced by better-auth catch-all route |
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
| Path | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/lib/supabase.ts` line 8 | Drop `!supabaseUrl.includes("supabase.co")` from `useMockData` check (only `NEXT_PUBLIC_USE_MOCK_DATA === "true"` should trigger mock) |
|
||||||
|
| `src/lib/admin-permissions.ts` | Use better-auth session + `pg.Pool` instead of `rc_auth_uid` cookie + Supabase REST |
|
||||||
|
| `src/middleware.ts` | Check for `rc_session_token` (better-auth) instead of `rc_auth_uid` |
|
||||||
|
| `src/actions/login.ts` | Use `authClient.signIn.email()` instead of `supabase.auth.signInWithPassword` |
|
||||||
|
| `src/actions/wholesale-auth.ts` | Use better-auth email sign-in |
|
||||||
|
| `src/actions/admin/force-login.ts` | Use `pg.Pool` for upsert (no Supabase) |
|
||||||
|
| `src/actions/brand-settings.ts` | Replace 8 Supabase fetch PUTs with `uploadFile()` |
|
||||||
|
| `src/actions/products/upload-image.ts` | Replace 3 Supabase calls with `uploadFile()` |
|
||||||
|
| `src/actions/communications/import-contacts.ts` | Replace PUT + LIST with S3 SDK |
|
||||||
|
| `src/app/api/water-photo-upload/route.ts` | Replace Supabase with `uploadFile()` |
|
||||||
|
| `src/lib/email-service.ts` | Use `publicUrl(BUCKETS.X, key)` for 4 hardcoded Supabase URLs |
|
||||||
|
| `src/components/admin/AdminHeader.tsx` | Use better-auth session |
|
||||||
|
| `src/components/admin/AdminSidebar.tsx` | Use better-auth session |
|
||||||
|
| `src/app/admin/me/AdminMeClient.tsx` | Use better-auth `authClient` for password change |
|
||||||
|
| `src/app/logout/page.tsx` | Use better-auth `authClient.signOut()` |
|
||||||
|
| `src/app/reset-password/page.tsx` | Use better-auth `authClient.changePassword()` |
|
||||||
|
| `src/components/storefront/TuxedoVideoHero.tsx` | Use `publicUrl()` for hardcoded Supabase URL |
|
||||||
|
| `src/components/time-tracking/TimeTrackingFieldClient.tsx` | Use `publicUrl()` |
|
||||||
|
| `src/app/tuxedo/about/page.tsx` | Use `publicUrl()` |
|
||||||
|
| `src/app/indian-river-direct/stops/page.tsx` | Use `publicUrl()` |
|
||||||
|
| `supabase/migrations/006_water_log_rpcs_fixed.sql` | Patch: `STATIC` → `STABLE` (6 occurrences) |
|
||||||
|
| `supabase/migrations/099_harvest_reach_segmentation.sql` | Patch: quote `time` column references |
|
||||||
|
| `supabase/migrations/135_email_automation_rpcs.sql` | Patch: reorder `enroll_abandoned_cart` params or add default to `p_next_email_at` |
|
||||||
|
| `package.json` | Add `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner`, `better-auth`, `kysely`, `pg` |
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Failure | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `pg_dump` from Supabase fails | Retry once, then halt and surface the error. Do not proceed with a partial dump. |
|
||||||
|
| Migration apply fails | Continue through all 137, collect failures in a log file, document them as a follow-up commit. |
|
||||||
|
| better-auth session invalid / expired | `getAdminUser()` returns `null`; pages redirect to `/login` via middleware. |
|
||||||
|
| PostgREST down | supabase-js calls throw; server actions return `{ success: false, error: "Database unavailable" }`. |
|
||||||
|
| MinIO down | Upload returns 500; UI shows "Upload failed, please retry." |
|
||||||
|
| `auth.uid()` returns NULL inside SECURITY DEFINER function | Per plan.md Phase D Option 1: `ALTER TABLE … DISABLE ROW LEVEL SECURITY` on all `public.*` tables after `pg_dump` apply. Functions still execute with owner privileges; brand scoping happens at the function-body / app layer. |
|
||||||
|
| RLS policies left over from `pg_dump` | Same as above: `DISABLE ROW LEVEL SECURITY` removes the block. |
|
||||||
|
|
||||||
|
## RLS Strategy (Phase D detail)
|
||||||
|
|
||||||
|
The 185 SECURITY DEFINER functions reference `auth.uid()`. The preflight stubs it to read `current_setting('request.jwt.claim.sub')`. In production this would be set by PostgREST from the JWT. Without it, `auth.uid()` returns NULL.
|
||||||
|
|
||||||
|
**Decision: Disable RLS on all `public.*` tables.**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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 $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is consistent with the existing "brand scoping in server actions" pattern documented in CLAUDE.md. The app already threads `effectiveBrandId = brandId ?? adminUser.brand_id ?? null` to every RPC. SECURITY DEFINER functions still execute with the function owner's privileges; RLS doesn't block them when disabled. The alternative (wiring PostgREST JWT → `request.jwt.claim.sub`) is more complex and not justified by current usage.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
End-to-end verification sequence (per plan.md Phase E, expanded):
|
||||||
|
|
||||||
|
1. **DB schema + data apply cleanly.**
|
||||||
|
- Run `pg_dump --schema-and-data --no-owner --no-privileges --schema=public --exclude-schema=auth --exclude-schema=storage "postgresql://postgres:<pw>@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" -f supabase/captured_schema.sql` from the user's Mac (direct PG blocked from this dev box per MEMORY.md).
|
||||||
|
- `docker compose up -d db postgrest minio minio_init`.
|
||||||
|
- Apply preflight, captured schema, then all 137 migrations. Document any remaining failures.
|
||||||
|
- Apply RLS disable block. (The 3 patched migrations 006/099/135 are applied as part of the 137-migration batch — verify each succeeded.)
|
||||||
|
2. **PostgREST smoke.**
|
||||||
|
- `curl http://127.0.0.1:3001/brands?limit=1 -H "apikey: <anon>"` → 200.
|
||||||
|
- `curl -X POST http://127.0.0.1:3001/rpc/get_public_stops_for_brand -H "Content-Type: application/json" -d '{"p_slug":"indian-river-direct"}'` → 200 with non-empty array.
|
||||||
|
3. **MinIO buckets public.**
|
||||||
|
- `mc alias set local http://127.0.0.1:9000 <user> <pass>`.
|
||||||
|
- `mc ls local/` shows all 5 buckets.
|
||||||
|
- `curl -I http://127.0.0.1:9000/brand-logos/test.png` → 200 or 404, never 403.
|
||||||
|
4. **App build.**
|
||||||
|
- `npx tsc --noEmit && npm run build` with the new env vars. Goal: no Supabase URL parse errors, no `auth.uid()` undefined, no missing bucket errors.
|
||||||
|
5. **Auth round-trip.**
|
||||||
|
- `POST /api/auth/sign-up/email` with test email/password → 200, user created in `user` table.
|
||||||
|
- `POST /api/auth/sign-in/email` → 200, `rc_session_token` cookie set.
|
||||||
|
- Hit `/admin` with the cookie → 200, not redirect to `/login`.
|
||||||
|
6. **Storage round-trip.**
|
||||||
|
- Start dev server (`npm run dev`).
|
||||||
|
- Sign in via better-auth.
|
||||||
|
- Upload a product image via admin UI → verify it lands in MinIO (`mc ls local/product-images/`).
|
||||||
|
- Visit `/indian-river-direct/products/[slug]` → image renders with the MinIO URL.
|
||||||
|
7. **Data fidelity spot check.**
|
||||||
|
- Pick 3 known data points (a specific brand, an order, a contact) and verify they match what's in the live Supabase.
|
||||||
|
8. **Playwright E2E.**
|
||||||
|
- Update env in `playwright.config.ts` (or `.env.test`).
|
||||||
|
- `npx playwright test` passes.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Migration apply order | `pg_dump` puts everything in dependency order; applying it first resolves most cross-references. Apply preflight first to stub `auth`, then captured schema, then migrations in numeric order. |
|
||||||
|
| The 3 patched migrations are load-bearing | 006 (water-log RPCs) is critical for `/admin/water-log`; 099 (harvest reach segmentation) is critical for `/admin/communications`; 135 (email automation) is critical for abandoned cart + welcome sequence. If patches don't work, those features break. Document as follow-ups. (087 is in the deleted list since Supabase Storage is replaced by MinIO.) |
|
||||||
|
| `pg_dump` includes conflicting `auth.uid()` body | The preflight creates the stub. If `pg_dump` redefines it, apply `pg_dump` first, then re-apply preflight (CREATE OR REPLACE FUNCTION handles re-definition). |
|
||||||
|
| Existing user-uploaded images unreachable in new stack | User will re-upload brand logos and product images. The Tuxedo video + Olathe logos (referenced in `email-service.ts` and `tuxedo/about/page.tsx`) need to be copied over manually to MinIO before the cutover. |
|
||||||
|
| PostgREST connection pool | PostgREST opens ~10 connections. Default Postgres `max_connections=100` is fine. |
|
||||||
|
| `dev_session` cookie and `rc_session_token` cookie coexist | Both checked in middleware. Dev mode bypass stays functional for local testing. |
|
||||||
|
| Mock mode regression | The `useMockData` check in `src/lib/supabase.ts` line 8 currently triggers on `!supabaseUrl.includes("supabase.co")` — this would falsely trigger against `http://localhost:3001`. Fix: drop the `.includes("supabase.co")` condition; rely on `NEXT_PUBLIC_USE_MOCK_DATA` flag only. |
|
||||||
|
| Two branches had different `.env.example` | Conflict during cherry-pick. Resolution: use the union with comments labeling each section's source. |
|
||||||
|
|
||||||
|
## Implementation Phases (overview; full detail in the merged `plan.md` + new task list)
|
||||||
|
|
||||||
|
1. **Phase 0 — Merge** — Create `selfhost/migrate`, cherry-pick both branches, resolve conflicts, fix `useMockData` check, update Gitea deploy workflow.
|
||||||
|
2. **Phase A — Capture base schema** — `pg_dump` from Supabase (user's Mac), restore to local Postgres, apply preflight + captured schema + 137 migrations + RLS disable.
|
||||||
|
3. **Phase B — MinIO** — Add MinIO + minio_init services to docker-compose, install AWS SDK, configure bucket policy.
|
||||||
|
4. **Phase C — Verify end-to-end** — Run the test sequence above. Fix any issues.
|
||||||
|
5. **Phase D — Cutover** — Update the prod server's env, run the same migration on the prod Postgres, restart services, verify with smoke tests. Supabase project stays live for rollback until prod has been verified for 24h.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **Bucket name `videos` for the Tuxedo hero** — currently in `TuxedoVideoHero.tsx` as a hardcoded Supabase URL. The new `src/lib/storage.ts` has `BUCKETS.VIDEOS = "videos"`. Need to manually copy the Tuxedo hero video file to MinIO before cutover.
|
||||||
|
- **Storage URL routing** — `NEXT_PUBLIC_STORAGE_BASE_URL` is `https://route.crispygoat.com/storage`. The deploy workflow needs a reverse proxy (Caddy or nginx) in front of MinIO on the prod server, OR MinIO port 9000 must be exposed via the existing domain. Decide: add Caddy to docker-compose, or use path-based routing in the existing reverse proxy.
|
||||||
|
- **Better-auth session table cleanup** — better-auth manages its own `session` table. Existing Supabase auth users will need to re-register (no password migration) OR a one-time SQL `INSERT INTO "user" SELECT … FROM auth.users` to seed better-auth users. Decide based on how many active users exist.
|
||||||
|
|
||||||
|
## Out of Scope (explicit)
|
||||||
|
|
||||||
|
- Migrating user passwords from `auth.users` to `user.password` (better-auth). Will be handled as a one-time "reset your password" email blast in a follow-up.
|
||||||
|
- Performance tuning, indexing strategy, or connection pooling beyond defaults.
|
||||||
|
- Replacing `@supabase/ssr` (already deleted in better-auth branch) or `supabase-js` (kept for PostgREST compatibility).
|
||||||
|
- Migrating Supabase Realtime subscriptions (none in current code).
|
||||||
|
- Migrating Supabase Edge Functions (none exist).
|
||||||
|
- Changing RLS to be useful instead of disabled (current pattern: brand scoping in app + SECURITY DEFINER).
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `selfhost/migrate` branch builds cleanly (`npx tsc --noEmit && npm run build`).
|
||||||
|
- [ ] `docker compose up` on the dev box starts Postgres + PostgREST + MinIO and all 137 migrations apply.
|
||||||
|
- [ ] All 3 patched migrations (006, 099, 135) apply without errors.
|
||||||
|
- [ ] `supabase-js` calls against `http://localhost:3001` return the same shape as before against Supabase.
|
||||||
|
- [ ] better-auth sign-up + sign-in round-trip works in the browser.
|
||||||
|
- [ ] Product image upload via admin UI lands in MinIO and renders on the storefront.
|
||||||
|
- [ ] Existing data (brands, products, orders, contacts) from the live Supabase dump appears in local Postgres.
|
||||||
|
- [ ] Mock mode still works when `NEXT_PUBLIC_USE_MOCK_DATA=true`.
|
||||||
|
- [ ] Dev mode bypass (`dev_session=platform_admin` cookie) still works.
|
||||||
|
- [ ] Gitea deploy workflow brings up the Docker stack on the prod server, applies migrations, and restarts the app.
|
||||||
|
- [ ] Supabase project remains live and unchanged until prod is verified for 24h post-cutover.
|
||||||
+21
-27
@@ -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);
|
||||||
|
|||||||
+29
-1
@@ -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
@@ -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",
|
||||||
|
|||||||
Executable
+160
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* apply-admin-create-stop.js
|
||||||
|
*
|
||||||
|
* Applies the admin_create_stop RPC using ONLY the keys in .env.local.
|
||||||
|
* You do NOT need Supabase dashboard / SQL editor access.
|
||||||
|
*
|
||||||
|
* This is the fix for the "could not find the function public.admin_create_stop(...)"
|
||||||
|
* error when you click "Add New Stop".
|
||||||
|
*
|
||||||
|
* Usage (run from the repo root on a machine that can reach the DB):
|
||||||
|
* node scripts/apply-admin-create-stop.js
|
||||||
|
*
|
||||||
|
* The script connects as the real postgres user (using your SUPABASE_SERVICE_ROLE_KEY
|
||||||
|
* as the password) and runs the CREATE OR REPLACE + GRANT + NOTIFY.
|
||||||
|
* This is the only reliable way to create the SECURITY DEFINER function because
|
||||||
|
* direct REST inserts are blocked by the `block_stops_mutations` policy.
|
||||||
|
*
|
||||||
|
* After it prints success:
|
||||||
|
* - Restart your `npm run dev`
|
||||||
|
* - "Add New Stop" (AddStopModal / NewStopForm) will now call the RPC successfully.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { Client } = require("pg");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
// Load .env.local (same as the rest of the project)
|
||||||
|
try {
|
||||||
|
require("dotenv").config({ path: path.resolve(__dirname, "../.env.local") });
|
||||||
|
} catch (e) {
|
||||||
|
// dotenv might not be hoisted in some setups; the keys may already be in process.env
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !serviceKey) {
|
||||||
|
console.error("❌ Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local");
|
||||||
|
console.error(" Make sure you are running from the repo root and .env.local exists with the keys.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the direct Postgres URL (same logic as supabase/push-migrations.js)
|
||||||
|
const projectRef = supabaseUrl.replace("https://", "").split(".")[0];
|
||||||
|
const pgHost = `db.${projectRef}.supabase.co`;
|
||||||
|
const dbUrl = `postgresql://postgres:${encodeURIComponent(serviceKey)}@${pgHost}:5432/postgres`;
|
||||||
|
|
||||||
|
console.log("🔧 apply-admin-create-stop RPC installer");
|
||||||
|
console.log(" Project:", projectRef);
|
||||||
|
console.log(" Using service role key from .env.local (full Postgres access)");
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const migrationPath = path.resolve(__dirname, "../supabase/migrations/202_fix_admin_create_stop.sql");
|
||||||
|
|
||||||
|
if (!fs.existsSync(migrationPath)) {
|
||||||
|
console.error("❌ Cannot find migration file:", migrationPath);
|
||||||
|
console.error(" The file should have been created by the previous fix.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = fs.readFileSync(migrationPath, "utf8");
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
connectionString: dbUrl,
|
||||||
|
ssl: { rejectUnauthorized: false },
|
||||||
|
// Some environments need a longer timeout
|
||||||
|
connectionTimeoutMillis: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("⏳ Connecting to Postgres (this uses port 5432 outbound)...");
|
||||||
|
await client.connect();
|
||||||
|
console.log("✅ Connected.");
|
||||||
|
|
||||||
|
console.log("⏳ Executing 202_fix_admin_create_stop.sql (CREATE OR REPLACE + GRANT + NOTIFY)...");
|
||||||
|
const result = await client.query(sql);
|
||||||
|
|
||||||
|
console.log("✅ Function installed / updated successfully.");
|
||||||
|
console.log(" Rows affected in last statement:", result.rowCount ?? "(see output above if any)");
|
||||||
|
console.log("");
|
||||||
|
console.log("🎉 admin_create_stop is now available via the anon key (SECURITY DEFINER).");
|
||||||
|
|
||||||
|
// Quick verification using the anon key over HTTPS (this is what the app uses)
|
||||||
|
try {
|
||||||
|
const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
const probeBody = {
|
||||||
|
p_active: false,
|
||||||
|
p_address: null,
|
||||||
|
p_brand_id: "00000000-0000-0000-0000-000000000000", // will FK fail, but proves the function exists
|
||||||
|
p_city: "verify",
|
||||||
|
p_cutoff_time: null,
|
||||||
|
p_date: "2099-12-31",
|
||||||
|
p_location: "verify",
|
||||||
|
p_state: "TS",
|
||||||
|
p_time: "10:00",
|
||||||
|
p_zip: null,
|
||||||
|
};
|
||||||
|
const probe = await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/admin_create_stop`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { apikey: anon, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(probeBody),
|
||||||
|
});
|
||||||
|
const txt = await probe.text();
|
||||||
|
if (probe.status === 404) {
|
||||||
|
console.log("⚠️ Post-apply probe still got 404 — you may need to wait a few seconds or the NOTIFY didn't take.");
|
||||||
|
} else if (/foreign key|violates|brand_id/i.test(txt)) {
|
||||||
|
console.log("✅ Post-apply probe: function exists (got expected FK error on the zero-UUID brand — this is success).");
|
||||||
|
} else {
|
||||||
|
console.log("✅ Post-apply probe returned:", txt.slice(0, 200));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(" (verification fetch skipped:", e.message, ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log("Next:");
|
||||||
|
console.log(" • Restart your dev server (`npm run dev`) if it was already running.");
|
||||||
|
console.log(" • Go to the stops admin page and try \"Add New Stop\".");
|
||||||
|
console.log(" • (Optional but recommended) node scripts/verify-stop-fns.js");
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err && err.message) ? err.message : String(err);
|
||||||
|
console.error("❌ Failed to apply the function.");
|
||||||
|
console.error(" Error:", msg.slice(0, 500));
|
||||||
|
|
||||||
|
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo|network is unreachable|ECONNREFUSED|timeout/i.test(msg)) {
|
||||||
|
console.log("");
|
||||||
|
console.log("💡 Your current environment cannot reach the database on port 5432.");
|
||||||
|
console.log(" This is common inside some containers/CI sandboxes.");
|
||||||
|
console.log("");
|
||||||
|
console.log(" Copy and run ONE of the following on a *normal* laptop/terminal that has");
|
||||||
|
console.log(" outbound access to Supabase Postgres (most developer machines can reach it):");
|
||||||
|
console.log("");
|
||||||
|
console.log(" Option A (easiest, uses exactly the same code as this script):");
|
||||||
|
console.log(" npm run migrate:one 202");
|
||||||
|
console.log("");
|
||||||
|
console.log(" Option B (psql):");
|
||||||
|
console.log(" # Build the URL using the key from your .env.local (same as this script does):");
|
||||||
|
console.log(" # psql \"postgresql://postgres:PUT_YOUR_SERVICE_ROLE_KEY_HERE@db." + projectRef + ".supabase.co:5432/postgres\" \\");
|
||||||
|
console.log(" # -f supabase/migrations/202_fix_admin_create_stop.sql");
|
||||||
|
console.log("");
|
||||||
|
console.log(" Option C (Supabase CLI):");
|
||||||
|
console.log(" supabase db push --db-url \"postgresql://postgres:YOUR_SERVICE_ROLE_KEY@db." + projectRef + ".supabase.co:5432/postgres\" \\");
|
||||||
|
console.log(" supabase/migrations/202_fix_admin_create_stop.sql");
|
||||||
|
console.log("");
|
||||||
|
console.log(" After the command succeeds you will see the NOTIFY and the function will exist.");
|
||||||
|
console.log(" Then restart `npm run dev` and \"Add New Stop\" will stop showing the function-not-found error.");
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
try { await client.end(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("Unexpected crash:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
||||||
|
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
apikey: serviceKey,
|
||||||
|
Authorization: `Bearer ${serviceKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function rpc(name, body = {}) {
|
||||||
|
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
|
||||||
|
method: "POST", headers, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return { status: r.status, text: (await r.text()).slice(0, 500) };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// 1. Try calling admin_create_stop with service role
|
||||||
|
console.log("=== 1. Call admin_create_stop (service role) ===");
|
||||||
|
console.log(await rpc("admin_create_stop", { p_brand_id: null }));
|
||||||
|
|
||||||
|
// 2. Try with body
|
||||||
|
console.log("\n=== 2. Call admin_create_stop with stub body ===");
|
||||||
|
console.log(await rpc("admin_create_stop", {
|
||||||
|
p_brand_id: "00000000-0000-0000-0000-000000000000",
|
||||||
|
p_city: "test", p_state: "CO", p_location: "x", p_date: "2025-01-01", p_time: "08:00"
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3. Try with a name that obviously doesn't exist to see the error format
|
||||||
|
console.log("\n=== 3. Call a non-existent function (control) ===");
|
||||||
|
console.log(await rpc("definitely_does_not_exist_xyz", {}));
|
||||||
|
|
||||||
|
// 4. Use the PostgREST introspection — query information_schema via rpc
|
||||||
|
// PostgREST exposes pg_proc through /rest/v1/ for system tables is not allowed
|
||||||
|
// But we can use the OpenAPI spec endpoint
|
||||||
|
console.log("\n=== 4. OpenAPI spec — looking for admin_create_stop ===");
|
||||||
|
const openApi = await fetch(`${url}/rest/v1/`, { headers: { apikey: anonKey, Authorization: `Bearer ${anonKey}` } });
|
||||||
|
const text = await openApi.text();
|
||||||
|
const matches = text.match(/admin_create_stop[a-z_]*/g) || [];
|
||||||
|
console.log("Matches in OpenAPI:", matches);
|
||||||
|
|
||||||
|
// 5. Get all function names from OpenAPI
|
||||||
|
const funcMatches = [...text.matchAll(/"([a-z][a-z0-9_]*)\s*\{/g)].map(m => m[1]);
|
||||||
|
const stopFns = funcMatches.filter(n => n.includes("stop"));
|
||||||
|
console.log("\nStop-related functions in OpenAPI:", stopFns);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
||||||
|
const { Client } = require("pg");
|
||||||
|
const dns = require("dns");
|
||||||
|
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const projectRef = url.replace("https://", "").split(".")[0];
|
||||||
|
const candidates = [
|
||||||
|
`db.${projectRef}.supabase.co`,
|
||||||
|
`aws-0-us-east-1.pooler.supabase.com`,
|
||||||
|
`aws-0-us-west-1.pooler.supabase.com`,
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const host of candidates) {
|
||||||
|
try {
|
||||||
|
const ip = await new Promise((resolve, reject) => {
|
||||||
|
dns.resolve4(host, (err, addrs) => err ? reject(err) : resolve(addrs));
|
||||||
|
});
|
||||||
|
console.log(`${host} -> ${ip.join(", ")}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`${host} -> DNS FAIL: ${e.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try direct DB with port 6543 (pooler) using supabase format
|
||||||
|
// Username pattern: postgres.<project-ref>
|
||||||
|
const client = new Client({
|
||||||
|
host: "aws-0-us-east-1.pooler.supabase.com",
|
||||||
|
port: 6543, database: "postgres",
|
||||||
|
user: `postgres.${projectRef}`,
|
||||||
|
password: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||||
|
ssl: { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const r = await client.query(`
|
||||||
|
SELECT p.proname,
|
||||||
|
pg_get_function_identity_arguments(p.oid) AS id_args,
|
||||||
|
p.prosecdef AS secdef
|
||||||
|
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
|
||||||
|
WHERE n.nspname='public' AND p.proname IN
|
||||||
|
('admin_create_stop','admin_create_stops_batch','delete_stop')
|
||||||
|
ORDER BY p.proname;
|
||||||
|
`);
|
||||||
|
console.log("\nFUNCTIONS IN DB:");
|
||||||
|
console.log(JSON.stringify(r.rows, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("POOLER ERR:", e.message);
|
||||||
|
} finally {
|
||||||
|
try { await client.end(); } catch {}
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
||||||
|
const { Client } = require("pg");
|
||||||
|
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const projectRef = url.replace("https://", "").split(".")[0];
|
||||||
|
const pw = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
const tries = [
|
||||||
|
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: `postgres.${projectRef}`, database: "postgres" },
|
||||||
|
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 5432, user: `postgres.${projectRef}`, database: "postgres" },
|
||||||
|
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: "postgres", database: "postgres" },
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const cfg of tries) {
|
||||||
|
const client = new Client({ ...cfg, password: pw, ssl: { rejectUnauthorized: false } });
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const r = await client.query(`
|
||||||
|
SELECT p.proname,
|
||||||
|
pg_get_function_identity_arguments(p.oid) AS id_args,
|
||||||
|
p.prosecdef AS secdef
|
||||||
|
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
|
||||||
|
WHERE n.nspname='public' AND p.proname IN
|
||||||
|
('admin_create_stop','admin_create_stops_batch','delete_stop')
|
||||||
|
ORDER BY p.proname;
|
||||||
|
`);
|
||||||
|
console.log(`\n✓ ${cfg.host}:${cfg.port} user=${cfg.user}`);
|
||||||
|
console.log(JSON.stringify(r.rows, null, 2));
|
||||||
|
|
||||||
|
// Also check if migration tracking table exists
|
||||||
|
const trk = await client.query(`
|
||||||
|
SELECT version, name FROM supabase_migrations.schema_migrations
|
||||||
|
WHERE version >= 145 ORDER BY version;
|
||||||
|
`).catch(() => ({ rows: [] }));
|
||||||
|
console.log("Recent migrations applied:", trk.rows);
|
||||||
|
await client.end();
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`✗ ${cfg.host}:${cfg.port} user=${cfg.user} → ${e.message}`);
|
||||||
|
try { await client.end(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* verify-stop-fns.js
|
||||||
|
*
|
||||||
|
* Confirms `admin_create_stop` and `admin_create_stops_batch` exist in the
|
||||||
|
* live database with the exact signatures the client uses, and that the
|
||||||
|
* PostgREST schema cache has them registered.
|
||||||
|
*
|
||||||
|
* Run: node scripts/verify-stop-fns.js
|
||||||
|
*
|
||||||
|
* Replaces the older `check-stop-fns*.js` scripts (left in the repo for
|
||||||
|
* history; this one is the canonical post-fix verification).
|
||||||
|
*
|
||||||
|
* Exits 0 on success, 1 if any check fails.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
||||||
|
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
if (!url || !serviceKey || !anonKey) {
|
||||||
|
console.error("Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / NEXT_PUBLIC_SUPABASE_ANON_KEY in .env.local");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICE_HEADERS = {
|
||||||
|
apikey: serviceKey,
|
||||||
|
Authorization: `Bearer ${serviceKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANON_HEADERS = {
|
||||||
|
apikey: anonKey,
|
||||||
|
Authorization: `Bearer ${anonKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
let failed = 0;
|
||||||
|
const fail = (msg) => { console.error("✗ " + msg); failed += 1; };
|
||||||
|
const pass = (msg) => console.log("✓ " + msg);
|
||||||
|
|
||||||
|
async function rpc(name, body, headers = SERVICE_HEADERS) {
|
||||||
|
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
let parsed;
|
||||||
|
try { parsed = JSON.parse(text); } catch { parsed = text.slice(0, 200); }
|
||||||
|
return { status: r.status, body: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log(`\nVerifying stop RPCs against ${url}\n`);
|
||||||
|
|
||||||
|
// ── 1. admin_create_stop — empty body (expect 400/422, NOT PGRST202) ─────
|
||||||
|
// PGRST202 = function not in schema cache. We want a 400-style validation
|
||||||
|
// error here, which proves PostgREST knows the function exists.
|
||||||
|
console.log("1. admin_create_stop signature check (PostgREST cache)");
|
||||||
|
const probe1 = await rpc("admin_create_stop", {});
|
||||||
|
if (probe1.status === 404) {
|
||||||
|
fail("admin_create_stop returned 404 — function not found (PGRST202 cache miss). " +
|
||||||
|
"Did you apply migration 147 and run `NOTIFY pgrst, 'reload schema'`?");
|
||||||
|
} else if (probe1.status >= 500) {
|
||||||
|
fail(`admin_create_stop returned ${probe1.status} — server error. Body: ${JSON.stringify(probe1.body)}`);
|
||||||
|
} else {
|
||||||
|
pass(`admin_create_stop reachable in PostgREST (status ${probe1.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. admin_create_stop — happy path with a real (but harmless) brand ──
|
||||||
|
console.log("\n2. admin_create_stop happy-path call (anon key)");
|
||||||
|
const probe2 = await rpc(
|
||||||
|
"admin_create_stop",
|
||||||
|
{
|
||||||
|
p_brand_id: "00000000-0000-0000-0000-000000000000",
|
||||||
|
p_city: "verify-script-city",
|
||||||
|
p_state: "CO",
|
||||||
|
p_location: "verification stop",
|
||||||
|
p_date: "2099-12-31",
|
||||||
|
p_time: "08:00 AM – 02:00 PM",
|
||||||
|
p_address: "123 Verify St",
|
||||||
|
p_zip: "80000",
|
||||||
|
p_cutoff_time: "08:00", // time-only — should be combined with date
|
||||||
|
p_active: false,
|
||||||
|
},
|
||||||
|
ANON_HEADERS
|
||||||
|
);
|
||||||
|
if (probe2.status === 404) {
|
||||||
|
fail("admin_create_stop not callable via anon key. " +
|
||||||
|
"GRANT EXECUTE missing? Body: " + JSON.stringify(probe2.body));
|
||||||
|
} else if (probe2.status >= 400) {
|
||||||
|
// Expected: brand_id is the zero UUID (foreign key violation). That's fine —
|
||||||
|
// it proves the function executed past type validation.
|
||||||
|
const msg = JSON.stringify(probe2.body);
|
||||||
|
if (/foreign key|violates/i.test(msg)) {
|
||||||
|
pass(`admin_create_stop ran end-to-end (got expected FK violation: ${msg.slice(0, 120)}...)`);
|
||||||
|
} else {
|
||||||
|
fail(`admin_create_stop failed unexpectedly (${probe2.status}): ${msg}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pass(`admin_create_stop returned success: ${JSON.stringify(probe2.body)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. admin_create_stops_batch — same checks ───────────────────────────
|
||||||
|
console.log("\n3. admin_create_stops_batch signature check");
|
||||||
|
const probe3 = await rpc("admin_create_stops_batch", { p_brand_id: null, p_stops: [] });
|
||||||
|
if (probe3.status === 404) {
|
||||||
|
fail("admin_create_stops_batch returned 404 — function not in schema cache.");
|
||||||
|
} else {
|
||||||
|
pass(`admin_create_stops_batch reachable (status ${probe3.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. OpenAPI introspection — confirm functions are exposed ────────────
|
||||||
|
console.log("\n4. OpenAPI introspection");
|
||||||
|
const openApi = await fetch(`${url}/rest/v1/`, { headers: ANON_HEADERS });
|
||||||
|
const text = await openApi.text();
|
||||||
|
const hasSingle = /admin_create_stop\b/.test(text);
|
||||||
|
const hasBatch = /admin_create_stops_batch\b/.test(text);
|
||||||
|
if (hasSingle) pass("admin_create_stop appears in OpenAPI spec");
|
||||||
|
else fail("admin_create_stop NOT in OpenAPI spec — PostgREST hasn't seen it yet");
|
||||||
|
if (hasBatch) pass("admin_create_stops_batch appears in OpenAPI spec");
|
||||||
|
else fail("admin_create_stops_batch NOT in OpenAPI spec");
|
||||||
|
|
||||||
|
console.log(`\n${failed === 0 ? "✓ All checks passed" : `✗ ${failed} check(s) failed`}\n`);
|
||||||
|
process.exit(failed === 0 ? 0 : 1);
|
||||||
|
})().catch((e) => {
|
||||||
|
console.error("verify-stop-fns.js crashed:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +15,7 @@ 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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,14 @@ async function callAIAnalysis(
|
|||||||
rows: string[][],
|
rows: string[][],
|
||||||
brandId: string
|
brandId: string
|
||||||
): Promise<ImportAnalysis> {
|
): Promise<ImportAnalysis> {
|
||||||
const apiKey = process.env.OPENAI_API_KEY;
|
// Prefer MiniMax (env-level) — the team is using it pre-launch. Fall back to OpenAI.
|
||||||
|
const provider: "minimax" | "openai" =
|
||||||
|
process.env.MINIMAX_API_KEY ? "minimax" : process.env.OPENAI_API_KEY ? "openai" : "openai";
|
||||||
|
const apiKey = provider === "minimax" ? process.env.MINIMAX_API_KEY! : process.env.OPENAI_API_KEY!;
|
||||||
|
const baseURL = provider === "minimax"
|
||||||
|
? (process.env.MINIMAX_BASE_URL || "https://api.minimax.io/v1")
|
||||||
|
: "https://api.openai.com/v1";
|
||||||
|
const model = provider === "minimax" ? "MiniMax-M3" : "gpt-4o-mini";
|
||||||
|
|
||||||
// Build sample rows (first 30 for token economy)
|
// Build sample rows (first 30 for token economy)
|
||||||
const sampleRows = rows.slice(0, 30);
|
const sampleRows = rows.slice(0, 30);
|
||||||
@@ -174,21 +181,23 @@ Rules:
|
|||||||
- Do NOT add __entity_type to cleanedRows — only in columnMappings output`;
|
- Do NOT add __entity_type to cleanedRows — only in columnMappings output`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
const res = await fetch(`${baseURL}/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: "gpt-4o-mini",
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: `Headers: ${headers.join(", ")}\n\nData (first 30 rows):\n${sampleText.slice(0, 6000)}` },
|
{ role: "user", content: `Headers: ${headers.join(", ")}\n\nData (first 30 rows):\n${sampleText.slice(0, 6000)}` },
|
||||||
],
|
],
|
||||||
response_format: { type: "json_object" },
|
// Note: response_format is OpenAI-specific. MiniMax /v1/chat/completions supports
|
||||||
|
// JSON-style prompting but may not honor `json_object`. We omit it for MiniMax.
|
||||||
|
...(provider === "openai" ? { response_format: { type: "json_object" } } : {}),
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`OpenAI error: ${res.status}`);
|
if (!res.ok) throw new Error(`${provider} error: ${res.status}`);
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const parsed = JSON.parse(data.choices[0].message.content as string);
|
const parsed = JSON.parse(data.choices[0].message.content as string);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized Billing Overview
|
||||||
|
*
|
||||||
|
* Single source of truth for everything the billing page + dashboard usage
|
||||||
|
* bars need to render. Replaces ad-hoc calls to `getBrandPlanInfo`,
|
||||||
|
* `getEnabledAddons`, brand-row subscription columns, and hard-coded invoice
|
||||||
|
* data with one consistent, denormalized payload.
|
||||||
|
*
|
||||||
|
* Why a single action:
|
||||||
|
* - Before this, the billing page combined `getBrandPlanInfo` +
|
||||||
|
* `getEnabledAddons` + a brands-table read of subscription fields +
|
||||||
|
* hard-coded mock invoices. The dashboard's product count came from
|
||||||
|
* `getDashboardStats` which used a different filter than `get_brand_plan_info`
|
||||||
|
* (`active=true` vs `deleted_at IS NULL`), causing "Active Products 1" vs
|
||||||
|
* "Products 0/25" in the same UI.
|
||||||
|
* - Add-ons could be "Active" in `brand_features` while no Stripe
|
||||||
|
* subscription existed, with a Remove button that would fail at Stripe.
|
||||||
|
*
|
||||||
|
* What this returns:
|
||||||
|
* - planTier + planCycle (we default to "monthly" because the brand row
|
||||||
|
* doesn't store a cycle — the UI toggle controls display, but a real
|
||||||
|
* cycle would be detected from a `stripe_subscription.items.data[0].price
|
||||||
|
* .recurring.interval` when fetched)
|
||||||
|
* - hasActiveSubscription + subscriptionStatus (so the UI can hide
|
||||||
|
* misleading "Active" add-on badges / Remove buttons)
|
||||||
|
* - usage: { users, stops_this_month, products } — products counted
|
||||||
|
* consistently with the dashboard (`active=true AND deleted_at IS NULL`).
|
||||||
|
* - enabledAddons with a derived `removable` flag — only true when
|
||||||
|
* there is an active subscription AND the feature is enabled AND the
|
||||||
|
* current user can manage settings.
|
||||||
|
* - displayedInvoiceAmount — monthly or annual equivalent matching the
|
||||||
|
* billingCycle toggle, summed across plan + active add-ons. Used by the
|
||||||
|
* invoice table so amounts always match the displayed plan.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||||
|
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
|
||||||
|
|
||||||
|
export type BillingSubscriptionStatus =
|
||||||
|
| "active"
|
||||||
|
| "trialing"
|
||||||
|
| "past_due"
|
||||||
|
| "canceled"
|
||||||
|
| "incomplete"
|
||||||
|
| "unpaid"
|
||||||
|
| "inactive"
|
||||||
|
| "none";
|
||||||
|
|
||||||
|
export type BillingOverview = {
|
||||||
|
brandId: string;
|
||||||
|
brandName: string | null;
|
||||||
|
planTier: PlanTierKey;
|
||||||
|
planCycle: "monthly" | "annual";
|
||||||
|
planMonthlyPrice: number;
|
||||||
|
planAnnualPrice: number;
|
||||||
|
hasStripeCustomer: boolean;
|
||||||
|
hasActiveSubscription: boolean;
|
||||||
|
subscriptionStatus: BillingSubscriptionStatus | null;
|
||||||
|
currentPeriodEnd: string | null;
|
||||||
|
limits: { max_users: number; max_stops_monthly: number; max_products: number };
|
||||||
|
usage: { users: number; stops_this_month: number; products: number };
|
||||||
|
enabledAddons: Record<string, boolean>;
|
||||||
|
// Denormalized add-on list (for direct UI rendering)
|
||||||
|
addons: Array<{
|
||||||
|
key: AddonKey;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
removable: boolean;
|
||||||
|
monthlyPrice: number;
|
||||||
|
annualPrice: number;
|
||||||
|
}>;
|
||||||
|
// Pre-computed totals so the billing page never disagrees with itself
|
||||||
|
displayedInvoiceAmount: number; // current cycle total
|
||||||
|
monthlyTotal: number; // always monthly equivalent
|
||||||
|
annualTotal: number; // always annual equivalent
|
||||||
|
isPlatformAdmin: boolean;
|
||||||
|
canManageBilling: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single, consistent billing summary for a brand.
|
||||||
|
* Safe to call from a server component (uses service-role key).
|
||||||
|
*/
|
||||||
|
export async function getBillingOverview(
|
||||||
|
brandId: string,
|
||||||
|
options?: { planCycle?: "monthly" | "annual" }
|
||||||
|
): Promise<{ success: boolean; data?: BillingOverview; error?: string }> {
|
||||||
|
try {
|
||||||
|
if (!brandId) return { success: false, error: "brandId required" };
|
||||||
|
|
||||||
|
// 1) Plan info (plan_tier + limits + usage via get_brand_plan_info)
|
||||||
|
const planRes = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_brand_id: brandId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const planData = planRes.ok ? await planRes.json() : null;
|
||||||
|
|
||||||
|
// 2) Brand row: subscription state, name, stripe_customer_id
|
||||||
|
const brandRes = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name,stripe_customer_id,stripe_subscription_id,stripe_subscription_status,stripe_current_period_end`,
|
||||||
|
{ headers: svcHeaders(supabaseKey) }
|
||||||
|
);
|
||||||
|
const brandRows = brandRes.ok ? await brandRes.json() : [];
|
||||||
|
const brand = Array.isArray(brandRows) ? brandRows[0] : null;
|
||||||
|
|
||||||
|
// 3) Enabled add-ons (feature flags)
|
||||||
|
const addonsRes = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_brand_id: brandId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const enabledAddons = addonsRes.ok ? await addonsRes.json() : {};
|
||||||
|
|
||||||
|
// 4) Active product count — same semantics as the dashboard
|
||||||
|
// (active=true AND deleted_at IS NULL). Keeps the billing page in
|
||||||
|
// sync with /admin "Active Products" stat.
|
||||||
|
const productRes = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/products?select=id&brand_id=eq.${brandId}&active=eq.true&deleted_at=is.null&limit=1000`,
|
||||||
|
{ headers: { ...svcHeaders(supabaseKey), Prefer: "count=exact" } }
|
||||||
|
);
|
||||||
|
const productCountHeader = productRes.headers.get("Content-Range");
|
||||||
|
let activeProductCount = 0;
|
||||||
|
if (productCountHeader && productCountHeader.includes("/")) {
|
||||||
|
const total = productCountHeader.split("/").pop();
|
||||||
|
activeProductCount = parseInt(total ?? "0", 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Admin context (so we know if the user can manage / is platform)
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
const isPlatformAdmin = adminUser?.role === "platform_admin";
|
||||||
|
const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings;
|
||||||
|
|
||||||
|
// ── Normalize plan data ──────────────────────────────────────────────────
|
||||||
|
const planTier = (planData?.plan_tier ?? "starter") as PlanTierKey;
|
||||||
|
const limits = {
|
||||||
|
max_users: planData?.max_users ?? 1,
|
||||||
|
max_stops_monthly: planData?.max_stops_monthly ?? 10,
|
||||||
|
max_products: planData?.max_products ?? 25,
|
||||||
|
};
|
||||||
|
const planInfo = PLAN_TIERS[planTier] ?? PLAN_TIERS.starter;
|
||||||
|
const planMonthlyPrice = planInfo.monthlyPrice;
|
||||||
|
const planAnnualPrice = planInfo.annualPrice;
|
||||||
|
|
||||||
|
// ── Normalize subscription state ─────────────────────────────────────────
|
||||||
|
const rawStatus = (brand?.stripe_subscription_status ?? null) as BillingSubscriptionStatus | null;
|
||||||
|
const hasStripeCustomer = !!brand?.stripe_customer_id;
|
||||||
|
const hasActiveSubscription =
|
||||||
|
rawStatus === "active" || rawStatus === "trialing";
|
||||||
|
|
||||||
|
// ── Build add-on list with derived `removable` ──────────────────────────
|
||||||
|
const addons: BillingOverview["addons"] = (Object.keys(ADDONS) as AddonKey[]).map((key) => {
|
||||||
|
const cfg = ADDONS[key];
|
||||||
|
const enabled = !!enabledAddons?.[key];
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: cfg.label,
|
||||||
|
icon: cfg.icon,
|
||||||
|
description: cfg.description,
|
||||||
|
enabled,
|
||||||
|
removable: enabled && hasActiveSubscription && canManageBilling,
|
||||||
|
monthlyPrice: cfg.monthlyPrice,
|
||||||
|
annualPrice: cfg.annualPrice,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Usage (products override from active=true+not-deleted count) ───────
|
||||||
|
const usage = {
|
||||||
|
users: planData?.usage?.users ?? 0,
|
||||||
|
stops_this_month: planData?.usage?.stops_this_month ?? 0,
|
||||||
|
// Prefer the active+non-deleted count for cross-UI consistency.
|
||||||
|
products: activeProductCount || (planData?.usage?.products ?? 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Totals ───────────────────────────────────────────────────────────────
|
||||||
|
const addonsMonthlyTotal = addons
|
||||||
|
.filter((a) => a.enabled && hasActiveSubscription)
|
||||||
|
.reduce((s, a) => s + a.monthlyPrice, 0);
|
||||||
|
const addonsAnnualTotal = addons
|
||||||
|
.filter((a) => a.enabled && hasActiveSubscription)
|
||||||
|
.reduce((s, a) => s + a.annualPrice, 0);
|
||||||
|
|
||||||
|
const monthlyTotal = planMonthlyPrice + addonsMonthlyTotal;
|
||||||
|
const annualTotal = planAnnualPrice + addonsAnnualTotal;
|
||||||
|
|
||||||
|
const planCycle: "monthly" | "annual" =
|
||||||
|
options?.planCycle ?? "monthly"; // safer default — see header comment
|
||||||
|
|
||||||
|
const displayedInvoiceAmount =
|
||||||
|
planCycle === "annual"
|
||||||
|
? planAnnualPrice + addonsAnnualTotal
|
||||||
|
: planMonthlyPrice + addonsMonthlyTotal;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
brandId,
|
||||||
|
brandName: brand?.name ?? planData?.brand_name ?? null,
|
||||||
|
planTier,
|
||||||
|
planCycle,
|
||||||
|
planMonthlyPrice,
|
||||||
|
planAnnualPrice,
|
||||||
|
hasStripeCustomer,
|
||||||
|
hasActiveSubscription,
|
||||||
|
subscriptionStatus: rawStatus,
|
||||||
|
currentPeriodEnd: brand?.stripe_current_period_end ?? null,
|
||||||
|
limits,
|
||||||
|
usage,
|
||||||
|
enabledAddons: (enabledAddons ?? {}) as Record<string, boolean>,
|
||||||
|
addons,
|
||||||
|
displayedInvoiceAmount,
|
||||||
|
monthlyTotal,
|
||||||
|
annualTotal,
|
||||||
|
isPlatformAdmin,
|
||||||
|
canManageBilling,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { success: false, error: `Failed to build billing overview: ${message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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 }[] };
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
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 { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
|
export type AIProvider = AIProviderType;
|
||||||
|
|
||||||
export type AIProviderSettings = {
|
export type AIProviderSettings = {
|
||||||
provider: AIProvider;
|
provider: AIProvider;
|
||||||
@@ -25,11 +26,15 @@ export type CustomIntegration = {
|
|||||||
testEndpoint?: string;
|
testEndpoint?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// MiniMax (MiniMax) is OpenAI-compatible. Default to the global endpoint; allow
|
||||||
|
// override via MINIMAX_BASE_URL env var (e.g. https://api.minimaxi.com/v1 for CN).
|
||||||
|
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`,
|
||||||
@@ -70,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,
|
||||||
@@ -111,9 +116,26 @@ export async function getAIClient(brandId: string): Promise<{
|
|||||||
const settings = await getAIProviderSettings(brandId);
|
const settings = await getAIProviderSettings(brandId);
|
||||||
|
|
||||||
if (!settings.apiKey) {
|
if (!settings.apiKey) {
|
||||||
// Fall back to env var
|
// Fall back to env var. Check the saved provider first, then universal env keys.
|
||||||
const envKey = process.env.OPENAI_API_KEY;
|
const envKey =
|
||||||
if (!envKey) return { provider: "openai", model: "gpt-4o-mini", client: null, error: "No API key configured" };
|
process.env.MINIMAX_API_KEY ||
|
||||||
|
process.env.OPENAI_API_KEY ||
|
||||||
|
process.env.ANTHROPIC_API_KEY ||
|
||||||
|
process.env.XAI_API_KEY ||
|
||||||
|
process.env.GOOGLE_API_KEY;
|
||||||
|
if (!envKey) {
|
||||||
|
return { provider: settings.provider || "openai", model: DEFAULT_MODELS[settings.provider as Exclude<AIProvider, "custom">] ?? "gpt-4o-mini", client: null, error: "No API key configured" };
|
||||||
|
}
|
||||||
|
// If the saved provider is minimax, use MiniMax even from env
|
||||||
|
if (settings.provider === "minimax" || process.env.MINIMAX_API_KEY) {
|
||||||
|
const { OpenAI } = await import("openai");
|
||||||
|
const baseURL = process.env.MINIMAX_BASE_URL || MINIMAX_DEFAULT_BASE_URL;
|
||||||
|
return {
|
||||||
|
provider: "minimax",
|
||||||
|
model: settings.model || DEFAULT_MODELS.minimax,
|
||||||
|
client: new OpenAI({ apiKey: process.env.MINIMAX_API_KEY ?? envKey, baseURL }),
|
||||||
|
};
|
||||||
|
}
|
||||||
const { OpenAI } = await import("openai");
|
const { OpenAI } = await import("openai");
|
||||||
return {
|
return {
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
@@ -147,6 +169,16 @@ export async function getAIClient(brandId: string): Promise<{
|
|||||||
client: new XAIOpenAI({ apiKey: settings.apiKey, baseURL: "https://api.x.ai/v1" }),
|
client: new XAIOpenAI({ apiKey: settings.apiKey, baseURL: "https://api.x.ai/v1" }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "minimax": {
|
||||||
|
// MiniMax (MiniMax) is OpenAI-compatible — swap baseURL
|
||||||
|
const { OpenAI } = await import("openai");
|
||||||
|
const baseURL = process.env.MINIMAX_BASE_URL || MINIMAX_DEFAULT_BASE_URL;
|
||||||
|
return {
|
||||||
|
provider: "minimax",
|
||||||
|
model: settings.model || DEFAULT_MODELS.minimax,
|
||||||
|
client: new OpenAI({ apiKey: settings.apiKey, baseURL }),
|
||||||
|
};
|
||||||
|
}
|
||||||
case "custom": {
|
case "custom": {
|
||||||
if (!settings.customEndpoint) {
|
if (!settings.customEndpoint) {
|
||||||
// @ts-expect-error - intentionally returning extra property for error signaling
|
// @ts-expect-error - intentionally returning extra property for error signaling
|
||||||
@@ -174,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`,
|
||||||
@@ -202,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`,
|
||||||
@@ -231,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`,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`,
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export type AdminCreateOrderItem = {
|
||||||
|
product_id: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
fulfillment?: "pickup" | "ship";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminCreateOrderInput = {
|
||||||
|
customer_name: string;
|
||||||
|
customer_email?: string | null;
|
||||||
|
customer_phone?: string | null;
|
||||||
|
stop_id?: string | null; // null for shipping-only / manual
|
||||||
|
items: AdminCreateOrderItem[];
|
||||||
|
internal_notes?: string | null;
|
||||||
|
// Optional overrides; if omitted we can compute simple or let RPC default
|
||||||
|
tax_amount?: number;
|
||||||
|
discount_amount?: number;
|
||||||
|
discount_reason?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminCreateOrderResult =
|
||||||
|
| { success: true; orderId: string; order: any }
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
export async function createAdminOrder(
|
||||||
|
brandId: string | null,
|
||||||
|
input: AdminCreateOrderInput
|
||||||
|
): Promise<AdminCreateOrderResult> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) {
|
||||||
|
return { success: false, error: "Not authenticated" };
|
||||||
|
}
|
||||||
|
if (!adminUser.can_manage_orders) {
|
||||||
|
return { success: false, error: "Not authorized to create orders" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brand scoping
|
||||||
|
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||||
|
if (adminUser.role === "brand_admin" && adminUser.brand_id && effectiveBrandId !== adminUser.brand_id) {
|
||||||
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.customer_name?.trim()) {
|
||||||
|
return { success: false, error: "Customer name is required" };
|
||||||
|
}
|
||||||
|
if (!input.items || input.items.length === 0) {
|
||||||
|
return { success: false, error: "At least one item is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||||
|
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
|
||||||
|
|
||||||
|
// Build items for RPC (match checkout shape)
|
||||||
|
const rpcItems = input.items.map((i) => ({
|
||||||
|
product_id: i.product_id,
|
||||||
|
quantity: i.quantity,
|
||||||
|
price: i.price,
|
||||||
|
fulfillment: i.fulfillment ?? "pickup",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const idempotencyKey = randomUUID();
|
||||||
|
|
||||||
|
// For admin manual orders we pass minimal tax info (0 or provided). Full tax calc can be added later.
|
||||||
|
const taxAmount = input.tax_amount ?? 0;
|
||||||
|
const taxRate = 0;
|
||||||
|
const taxLocation = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
p_idempotency_key: idempotencyKey,
|
||||||
|
p_customer_name: input.customer_name.trim(),
|
||||||
|
p_customer_email: input.customer_email?.trim() || null,
|
||||||
|
p_customer_phone: input.customer_phone?.trim() || null,
|
||||||
|
p_stop_id: input.stop_id || null,
|
||||||
|
p_items: rpcItems,
|
||||||
|
p_tax_amount: taxAmount,
|
||||||
|
p_tax_rate: taxRate,
|
||||||
|
p_tax_location: taxLocation,
|
||||||
|
// The RPC may also accept brand scoping internally via stop or we can extend later.
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text().catch(() => "Unknown error");
|
||||||
|
return { success: false, error: `Failed to create order: ${errText}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data || !data.id) {
|
||||||
|
return { success: false, error: "Order created but no ID returned" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally attach internal notes via a follow-up update if the RPC doesn't support it directly.
|
||||||
|
if (input.internal_notes?.trim()) {
|
||||||
|
// Best-effort; don't fail the whole create if this secondary update fails.
|
||||||
|
try {
|
||||||
|
await fetch(`${supabaseUrl}/rest/v1/orders?id=eq.${data.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ internal_notes: input.internal_notes.trim() }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, orderId: data.id, order: data };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err?.message ?? "Unexpected error creating order" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
+79
-25
@@ -1,5 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -29,30 +30,28 @@ 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!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
// 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
|
||||||
|
// direct REST inserts against the RLS-blocked `stops` table.
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
|
||||||
|
|
||||||
const rows = stops.map((s) => {
|
const rows = stops.map((s) => ({
|
||||||
const slug = `${s.city.toLowerCase().replace(/\s+/g, "-")}-${s.date || new Date().toISOString().slice(0, 10)}`;
|
city: s.city,
|
||||||
return {
|
state: s.state,
|
||||||
city: s.city,
|
location: s.location,
|
||||||
state: s.state,
|
date: s.date || "",
|
||||||
location: s.location,
|
time: s.time || "",
|
||||||
date: s.date || "",
|
address: s.address || null,
|
||||||
time: s.time || "",
|
zip: s.zip || null,
|
||||||
address: s.address || null,
|
cutoff_time: null,
|
||||||
zip: s.zip || null,
|
active: false,
|
||||||
brand_id: effectiveBrandId,
|
}));
|
||||||
slug,
|
|
||||||
status: "draft",
|
|
||||||
active: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/stops`, {
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stops_batch`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(rows),
|
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_stops: rows }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -60,6 +59,9 @@ export async function createStopsBatch(
|
|||||||
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
|
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
revalidateTag("stops", "default");
|
||||||
|
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
|
||||||
|
|
||||||
const inserted = await res.json();
|
const inserted = await res.json();
|
||||||
return { success: true, created: Array.isArray(inserted) ? inserted.length : stops.length };
|
return { success: true, created: Array.isArray(inserted) ? inserted.length : stops.length };
|
||||||
}
|
}
|
||||||
@@ -72,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",
|
||||||
@@ -86,6 +88,9 @@ export async function publishStop(
|
|||||||
return { success: false, error: (err as { message?: string }).message ?? "Publish failed" };
|
return { success: false, error: (err as { message?: string }).message ?? "Publish failed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
revalidateTag("stops", "default");
|
||||||
|
revalidateTag(`brand:${brandId}:stops`, "default");
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,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(
|
||||||
@@ -117,3 +122,52 @@ export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
|||||||
const stops = await response.json();
|
const stops = await response.json();
|
||||||
return Array.isArray(stops) ? stops : [];
|
return Array.isArray(stops) ? stops : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch active stops for a brand by slug.
|
||||||
|
* Public action used by the storefront stop pages (e.g. /tuxedo/stops,
|
||||||
|
* /indian-river-direct/stops) — replaces the previous client-side
|
||||||
|
* `api.from("stops")` query so api-js no longer ships to
|
||||||
|
* the browser for those routes.
|
||||||
|
*
|
||||||
|
* Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')`
|
||||||
|
* from any stop mutation (see createStopsBatch, publishStop, etc.).
|
||||||
|
*/
|
||||||
|
export type PublicStop = {
|
||||||
|
id: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
location: string;
|
||||||
|
address: string | null;
|
||||||
|
slug: string;
|
||||||
|
cutoff_time: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getPublicStopsForBrand(
|
||||||
|
brandSlug: string
|
||||||
|
): Promise<PublicStop[]> {
|
||||||
|
if (!brandSlug) return [];
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||||
|
next: {
|
||||||
|
revalidate: 300,
|
||||||
|
tags: ["stops", `brand:${brandSlug}:stops`],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) return [];
|
||||||
|
|
||||||
|
const stops = await response.json();
|
||||||
|
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
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 }
|
||||||
@@ -32,40 +30,87 @@ 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 }>;
|
// Preferred path: anon key + SECURITY DEFINER RPC (bypasses RLS, works even if
|
||||||
return { success: true, id: `mock-stop-${Date.now()}` };
|
// service role key is absent at runtime). See:
|
||||||
}
|
// api/migrations/202_fix_admin_create_stop.sql
|
||||||
|
// scripts/apply-admin-create-stop.js (run this locally with the keys you have)
|
||||||
|
// api/ADMIN_CREATE_STOP_FIX.sql (exact prompt SQL for SQL editor)
|
||||||
|
const anonKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stop`, {
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
|
||||||
|
|
||||||
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date || new Date().toISOString().slice(0, 10)}`;
|
|
||||||
|
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/stops`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
headers: { ...svcHeaders(anonKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
city: data.city,
|
p_active: data.active ?? false,
|
||||||
state: data.state,
|
p_address: data.address || null,
|
||||||
location: data.location,
|
p_brand_id: brandId,
|
||||||
date: data.date,
|
p_city: data.city,
|
||||||
time: data.time,
|
p_cutoff_time: data.cutoff_time || null,
|
||||||
slug,
|
p_date: data.date,
|
||||||
brand_id: brandId,
|
p_location: data.location,
|
||||||
active: data.active ?? false,
|
p_state: data.state,
|
||||||
address: data.address ?? null,
|
p_time: data.time,
|
||||||
zip: data.zip ?? null,
|
p_zip: data.zip || null,
|
||||||
cutoff_time: data.cutoff_time ?? null,
|
|
||||||
status: "draft",
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let usedFallback = false;
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.text();
|
const errText = await res.text();
|
||||||
return { success: false, error: `Failed: ${err}` };
|
const lower = errText.toLowerCase();
|
||||||
|
const looksLikeMissingFn =
|
||||||
|
lower.includes("pgrst202") ||
|
||||||
|
lower.includes("admin_create_stop") ||
|
||||||
|
lower.includes("could not find the function") ||
|
||||||
|
lower.includes("function not found");
|
||||||
|
|
||||||
|
if (looksLikeMissingFn) {
|
||||||
|
usedFallback = true;
|
||||||
|
} else {
|
||||||
|
return { success: false, error: `Failed: ${errText}` };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const inserted = await res.json().catch(() => ({} as any));
|
||||||
|
if (inserted && inserted.success === false) {
|
||||||
|
// Our RPC returns structured errors as 200 + {success:false}
|
||||||
|
const errMsg = inserted.error || "Failed to create stop";
|
||||||
|
const lower = errMsg.toLowerCase();
|
||||||
|
if (lower.includes("function") || lower.includes("not found") || lower.includes("pgrst")) {
|
||||||
|
usedFallback = true;
|
||||||
|
} else {
|
||||||
|
return { success: false, error: errMsg };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Happy path from RPC (either old {id,slug} or new {success, stop_id})
|
||||||
|
const stopId = inserted?.stop_id || inserted?.id || "";
|
||||||
|
const effectiveBrandId = brandId || adminUser.brand_id;
|
||||||
|
if (effectiveBrandId) {
|
||||||
|
revalidateTag("stops", "default");
|
||||||
|
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
|
||||||
|
}
|
||||||
|
return { success: true, id: stopId };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inserted = await res.json();
|
if (usedFallback) {
|
||||||
return { success: true, id: inserted[0]?.id ?? "" };
|
// We cannot bypass the block_stops_mutations RLS policy via REST even with the service key.
|
||||||
|
// The only reliable path is the SECURITY DEFINER RPC created by migration 202.
|
||||||
|
// Tell the user exactly how to install it using only the keys they already have.
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"The admin_create_stop database function is missing (this is why Add New Stop fails with function not found).\n\n" +
|
||||||
|
"Fix using only the keys in your .env.local (no Supabase dashboard needed):\n" +
|
||||||
|
" 1. On a machine that can reach your database (normal laptop usually works):\n" +
|
||||||
|
" node scripts/apply-admin-create-stop.js\n" +
|
||||||
|
" 2. Or: npm run migrate:one 202\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.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not reach here
|
||||||
|
return { success: false, error: "Unexpected state creating stop" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,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,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`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -44,8 +44,14 @@ type WaterEntry = {
|
|||||||
// ── Headgate Admin ──────────────────────────────────────────
|
// ── Headgate Admin ──────────────────────────────────────────
|
||||||
|
|
||||||
export async function createWaterHeadgate(brandId: string, name: string, unit: string = "CFS"): Promise<{ success: boolean; headgate?: Headgate; error?: string }> {
|
export async function createWaterHeadgate(brandId: string, name: string, unit: string = "CFS"): Promise<{ success: boolean; headgate?: Headgate; error?: string }> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const adminUser = await (await import("@/lib/admin-permissions")).getAdminUser();
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_water_log && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Not authorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||||
|
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`,
|
||||||
@@ -75,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`,
|
||||||
@@ -105,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`,
|
||||||
@@ -136,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`,
|
||||||
@@ -166,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`,
|
||||||
@@ -199,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`,
|
||||||
@@ -226,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`,
|
||||||
@@ -253,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`,
|
||||||
@@ -268,18 +274,42 @@ export async function deleteWaterHeadgate(headgateId: string): Promise<{ success
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = await response.json();
|
// The RPC returns JSONB on success: { success: true } or { success: false, error: "..." }.
|
||||||
if (!response.ok || !data?.success) {
|
// On failure (HTTP non-2xx) Supabase returns { message: "...", code: "...", details: ... }.
|
||||||
return { success: false, error: data?.error ?? "Failed to delete headgate" };
|
// We try to extract the most useful message in both cases.
|
||||||
|
let data: { success?: boolean; error?: string; message?: string } | null = null;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch {
|
||||||
|
// Non-JSON body — leave data as null, fall through to default error
|
||||||
}
|
}
|
||||||
return { success: true };
|
|
||||||
|
if (response.ok && data?.success) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the RPC's own error if it set one
|
||||||
|
const errorMessage =
|
||||||
|
data?.error ??
|
||||||
|
data?.message ??
|
||||||
|
(response.ok ? "Unknown error" : `HTTP ${response.status}: ${response.statusText || "request failed"}`);
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.error("[deleteWaterHeadgate] failed", {
|
||||||
|
headgateId,
|
||||||
|
status: response.status,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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`,
|
||||||
@@ -296,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`,
|
||||||
@@ -318,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`,
|
||||||
@@ -349,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`,
|
||||||
@@ -381,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`,
|
||||||
@@ -404,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`,
|
||||||
@@ -451,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`,
|
||||||
@@ -481,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`,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
+41
-110
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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}`,
|
||||||
|
|||||||
@@ -14,30 +14,32 @@ if (typeof window !== "undefined") {
|
|||||||
export default function LandingPageClient() {
|
export default function LandingPageClient() {
|
||||||
const mainRef = useRef<HTMLDivElement>(null);
|
const mainRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Global scroll animations
|
// Global scroll animations (guarded to prevent missing-target warnings when no .scroll-reveal elements)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined" || !mainRef.current) return;
|
if (typeof window === "undefined" || !mainRef.current) return;
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
// Reveal animations for sections
|
// Reveal animations for sections - only if targets exist (prevents GSAP "missing targets" warnings)
|
||||||
const revealElements = gsap.utils.toArray<Element>(".scroll-reveal");
|
const revealElements = gsap.utils.toArray<Element>(".scroll-reveal");
|
||||||
revealElements.forEach((el) => {
|
if (revealElements.length > 0) {
|
||||||
gsap.fromTo(
|
revealElements.forEach((el) => {
|
||||||
el,
|
gsap.fromTo(
|
||||||
{ opacity: 0, y: 60 },
|
el,
|
||||||
{
|
{ opacity: 0, y: 60 },
|
||||||
opacity: 1,
|
{
|
||||||
y: 0,
|
opacity: 1,
|
||||||
duration: 1,
|
y: 0,
|
||||||
ease: "power3.out",
|
duration: 1,
|
||||||
scrollTrigger: {
|
ease: "power3.out",
|
||||||
trigger: el,
|
scrollTrigger: {
|
||||||
start: "top 85%",
|
trigger: el,
|
||||||
toggleActions: "play none none reverse",
|
start: "top 85%",
|
||||||
},
|
toggleActions: "play none none reverse",
|
||||||
}
|
},
|
||||||
);
|
}
|
||||||
});
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}, mainRef);
|
}, mainRef);
|
||||||
|
|
||||||
return () => ctx.revert();
|
return () => ctx.revert();
|
||||||
|
|||||||
@@ -1,9 +1,49 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default async function AdvancedSettingsPage() {
|
export default async function AdvancedSettingsPage() {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) redirect("/login");
|
if (!adminUser) redirect("/login");
|
||||||
|
|
||||||
redirect("/admin/settings#advanced");
|
const isPlatform = adminUser.role === "platform_admin";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link href="/admin/settings" className="text-sm text-stone-500 hover:text-stone-700">← Back to Settings</Link>
|
||||||
|
<h1 className="mt-2 text-3xl font-bold tracking-tight text-stone-950">Advanced Settings</h1>
|
||||||
|
<p className="mt-1 text-stone-600">Platform & AI configuration, feature flags, and integrations.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Link href="/admin/settings/ai" className="block rounded-2xl border bg-white p-6 hover:shadow">
|
||||||
|
<div className="font-semibold">AI Intelligence Pack</div>
|
||||||
|
<div className="text-sm text-stone-500 mt-1">Provider keys, model preferences, and usage for campaign writer, pricing advisor, etc.</div>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/settings/integrations" className="block rounded-2xl border bg-white p-6 hover:shadow">
|
||||||
|
<div className="font-semibold">Integrations</div>
|
||||||
|
<div className="text-sm text-stone-500 mt-1">Resend, Twilio, Stripe, Square, and custom AI providers.</div>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/settings/square-sync" className="block rounded-2xl border bg-white p-6 hover:shadow">
|
||||||
|
<div className="font-semibold">Square Sync</div>
|
||||||
|
<div className="text-sm text-stone-500 mt-1">Inventory & product sync configuration.</div>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/settings/shipping" className="block rounded-2xl border bg-white p-6 hover:shadow">
|
||||||
|
<div className="font-semibold">Shipping & FedEx</div>
|
||||||
|
<div className="text-sm text-stone-500 mt-1">Rates, label creation, and settings.</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isPlatform && (
|
||||||
|
<p className="mt-8 text-xs text-stone-500">Some advanced options are only visible to platform administrators.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8 text-xs text-stone-400">
|
||||||
|
These pages aggregate the real configuration surfaces. Feature flags live under Apps in the main settings.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,12 @@ export const metadata: Metadata = {
|
|||||||
description: "Create and send email campaigns to your customers.",
|
description: "Create and send email campaigns to your customers.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Legacy /compose route: now consolidated into the main Communications page.
|
||||||
|
// We render the same component with initialTab="compose" so users land directly
|
||||||
|
// in the unified CampaignComposerPage (no duplicate "edit" / "new" experience).
|
||||||
|
// The previous render passed editMode="new" which forced a separate
|
||||||
|
// CampaignEditPanel render in the campaigns tab — that duplicate has been
|
||||||
|
// removed. /compose is preserved for backwards compatibility (existing links).
|
||||||
export default async function ComposePage() {
|
export default async function ComposePage() {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser || !adminUser.can_manage_messages) {
|
if (!adminUser || !adminUser.can_manage_messages) {
|
||||||
@@ -31,7 +37,7 @@ export default async function ComposePage() {
|
|||||||
templates={templatesResult.success ? templatesResult.templates : []}
|
templates={templatesResult.success ? templatesResult.templates : []}
|
||||||
brandId={effectiveBrandId}
|
brandId={effectiveBrandId}
|
||||||
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
|
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
|
||||||
editMode="new"
|
initialTab="compose"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -153,8 +154,9 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
|||||||
{editing && (
|
{editing && (
|
||||||
<form onSubmit={handleSaveProfile} className="mt-6 space-y-4">
|
<form onSubmit={handleSaveProfile} className="mt-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Display Name</label>
|
<label htmlFor="me-display-name" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Display Name</label>
|
||||||
<input
|
<input
|
||||||
|
id="me-display-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
@@ -168,8 +170,9 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Phone Number</label>
|
<label htmlFor="me-phone" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Phone Number</label>
|
||||||
<input
|
<input
|
||||||
|
id="me-phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
value={phoneNumber}
|
value={phoneNumber}
|
||||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||||
@@ -180,6 +183,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
|||||||
backgroundColor: "white"
|
backgroundColor: "white"
|
||||||
}}
|
}}
|
||||||
placeholder="+1 (555) 000-0000"
|
placeholder="+1 (555) 000-0000"
|
||||||
|
autoComplete="tel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
@@ -228,11 +232,14 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
|||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleEmailChange} className="mt-4 space-y-3">
|
<form onSubmit={handleEmailChange} className="mt-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>New Email Address</label>
|
<label htmlFor="me-new-email" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>New Email Address</label>
|
||||||
<input
|
<input
|
||||||
|
id="me-new-email"
|
||||||
type="email"
|
type="email"
|
||||||
value={newEmail}
|
value={newEmail}
|
||||||
onChange={(e) => setNewEmail(e.target.value)}
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
aria-required="true"
|
||||||
className="mt-1 w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-1"
|
className="mt-1 w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-1"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "var(--admin-border)",
|
borderColor: "var(--admin-border)",
|
||||||
@@ -240,6 +247,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
|||||||
backgroundColor: "white"
|
backgroundColor: "white"
|
||||||
}}
|
}}
|
||||||
placeholder="new@example.com"
|
placeholder="new@example.com"
|
||||||
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{emailError && (
|
{emailError && (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user