8 Commits

Author SHA1 Message Date
tyler 32396af193 ci(gitea): fix deploy path — docker-compose.yml moved to deploy/
Deploy to route.crispygoat.com / deploy (push) Successful in 3m30s
The Start Docker stack and Deploy steps referenced docker-compose.yml at
the repo root, but the file moved to deploy/ as part of the deploy.sh
refactor. The cp commands failed with 'cannot stat docker-compose.yml'
and the deploy step aborted.

Updated both cp paths to deploy/docker-compose.yml. The cd $APP_DIR and
docker compose -f $APP_DIR/docker-compose.yml references are unchanged
because they point at the deployed copy in APP_DIR, not the repo.
2026-06-06 19:54:03 +00:00
tyler f36419be69 docs: document canonical Gitea remote in CLAUDE.md
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
There is exactly one remote (origin = git@git.crispygoat.com:tyler/route-commerce.git).
No GitHub remotes. Pushing to origin/main triggers .gitea/workflows/deploy.yml.
2026-06-06 19:49:46 +00:00
tyler 5477b3419f fix(build): make admin tree dynamic and catch Supabase fetch errors at build
Deploy to route.crispygoat.com / deploy (push) Failing after 4m16s
Two errors were aborting the Gitea build:

1. DYNAMIC_SERVER_USAGE on /admin/settings/square-sync (and any admin page):
   getAdminUser() reads cookies() via next/headers. The admin layout tried
   to prerender statically, so the first child page that hit cookies()
   aborted the build. Added 'export const dynamic = "force-dynamic"' to
   src/app/admin/layout.tsx so the whole admin tree opts out of static
   prerender.

2. Prerender ECONNREFUSED on /indian-river-direct/stops and the sitemap:
   getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic
   fetch NEXT_PUBLIC_SUPABASE_URL at build time. The Gitea runner sets the
   Supabase env vars (so the existing env-var guard passes) but the URL
   is unreachable, so fetch throws ECONNREFUSED and the prerender aborts.
   Wrapped each fetch in try/catch returning [] / {success: false} so the
   prerender completes; runtime behavior is unchanged when the fetch
   succeeds.

Also added force-dynamic to the square-sync page itself as belt-and-braces
in case the layout change doesn't propagate.
2026-06-06 19:46:35 +00:00
tyler 2f3be5426f fix(actions): skip Supabase fetch at build time when env vars unset
Deploy to route.crispygoat.com / deploy (push) Failing after 2m50s
The /indian-river-direct/stops page and sitemap prerender at build time
and call getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic.
Those actions fetch NEXT_PUBLIC_SUPABASE_URL via Supabase REST. During
the GitHub/Gitea build, the Supabase secret is unset (or the value is
".supabase.co" which doesn't resolve), so the fetch errors with
ECONNREFUSED and the build aborts.

Return [] / not-configured when the env vars are missing so the prerender
can complete. Runtime behavior is unchanged when the vars are set.
2026-06-06 05:12:55 +00:00
tyler 2d837bc786 ci(gitea): drop build workflow, simplify deploy to call deploy.sh
Deploy to route.crispygoat.com / deploy (push) Failing after 2m50s
- Delete .gitea/workflows/build.yml (typecheck/lint only; caused confusion)
- Rewrite .gitea/workflows/deploy.yml as a thin wrapper that calls
  ./deploy/deploy.sh, matching the design in deploy/GITEA_SETUP.md
  (Option B). The 14512-byte inline deploy is removed; deploy.sh is the
  source of truth for the deploy mechanism.
- Fix runs-on to [self-hosted, ubuntu-latest] (matches the actual labels
  registered on crispygoat-host-runner; the previous [.., linux, ..] was
  unmatchable, which is why runs were stuck in the queue)
2026-06-06 05:03:44 +00:00
tyler bb6dbe37a4 ci(gitea): add deploy workflow + self-hosted homelab deploy toolkit (Auth.js port)
Build / build (push) Has been cancelled
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
Ports the deploy pipeline from the Gitea main fork (commit 7ddb06d's deploy
toolkit) into the Auth.js v5 / NextAuth tree:

- .gitea/workflows/deploy.yml: inline deploy that brings up the Docker
  stack, applies migrations, builds Next.js, and runs the app under PM2.
  Swapped Better Auth env vars (BETTER_AUTH_SECRET/URL, NEXT_PUBLIC_BETTER_AUTH_URL)
  for Auth.js v5 names (AUTH_SECRET/URL, NEXT_PUBLIC_AUTH_URL). Dropped
  NEXT_PUBLIC_SUPABASE_URL/ANON_KEY (Supabase removal in progress). Added
  GOOGLE_CLIENT_ID/SECRET + ALLOW_DEV_LOGIN for the Auth.js Google provider
  and dev credentials path. Switched runs-on from 'ubuntu-latest' to the
  self-hosted runner labels matching build.yml.

- deploy/: idempotent deploy toolkit (deploy.sh, docker-compose.yml,
  Dockerfile.nextjs, nginx.conf.template, .env.production.example, healthcheck.sh,
  Makefile, deploy/.gitignore). No auth/Supabase dependencies — pure infra.

- deploy/.env.production.example: renamed NEXTAUTH_SECRET/NEXTAUTH_URL
  (v4) to AUTH_SECRET/AUTH_URL (v5) and added the v5-specific vars
  (NEXT_PUBLIC_AUTH_URL, GOOGLE_*, ALLOW_DEV_LOGIN).

Build pipeline is now end-to-end:
  build.yml → typecheck + lint + build (uses [self-hosted, linux, ubuntu-latest])
  deploy.yml → start docker stack + migrations + build + PM2 restart

Storage / admin code ports (MinIO via @/lib/storage, Supabase removal,
admin-permissions rewrite) are tracked separately — they require porting
the storage and admin code first; the deploy pipeline itself is ready
to run against the Auth.js world.
2026-06-06 04:24:53 +00:00
tyler 3f4f46da7e ci: retrigger Gitea build 2026-06-06 03:46:49 +00:00
tyler ec1506dc82 feat(auth): Auth.js v5 + Postgres adapter for local smoke test
Wire up NextAuth v5 with @auth/pg-adapter, JWT sessions (edge-friendly),
and a dev Credentials provider for local testing without Google OAuth.

Stack
- next-auth@5.0.0-beta.31, @auth/pg-adapter@1.11.2, @types/pg
- Google OAuth provider via GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
  (falls back to AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET)
- Postgres adapter wired to a single pg.Pool in src/lib/db.ts style —
  reads DATABASE_URL with SUPABASE_DB_URL / POSTGRES_URL fallbacks
- JWT session strategy (edge-safe) so the proxy can verify sessions
  without a DB round-trip

Files
- src/auth.config.ts        edge-safe config (Google + authorized cb)
- src/lib/auth.ts           server config (adapter + dev Credentials)
- src/proxy.ts              Next.js 16 proxy (was middleware.ts)
- src/app/api/auth/[...nextauth]/route.ts
                            catch-all handler
- src/app/protected-example/page.tsx
                            demo page that renders auth() session
- src/actions/auth-signin.ts
                            signInWithGoogle, signInWithDev,
                            signOutAction server actions
- src/app/login/LoginClient.tsx
                            added "Sign in with Google" + dev form
- supabase/migrations/204_authjs_tables.sql
                            users / accounts / sessions /
                            verification_token schema (UUID-keyed)
- .env.example              AUTH_SECRET, AUTH_URL, GOOGLE_CLIENT_*,
                            DATABASE_URL, ALLOW_DEV_LOGIN

Removed
- src/middleware.ts         deleted; Next.js 16 only runs one proxy
                            (the new src/proxy.ts is canonical)

Routes
- /login, /admin, /admin/*, /protected-example
                            proxy matcher
- /api/auth/{providers,csrf,signin/<provider>,callback/<provider>,
  session,signout}
                            standard Auth.js endpoints

Local dev
- npm run dev (now runs on port 4000)
- push migration 204 then visit /login
- dev signin works with any non-empty username/password
  (hidden when ALLOW_DEV_LOGIN=false)
- Google signin requires real GOOGLE_CLIENT_ID + redirect URI
  http://localhost:4000/api/auth/callback/google

Verified
- tsc --noEmit clean
- /admin, /admin/orders, /protected-example → 307 to /login
  when unauthenticated
- /api/auth/session returns user after signin
- /protected-example renders session info
- /api/auth/providers returns google + dev-login

Docs
- CLAUDE.md and MEMORY.md updated to reflect the Supabase → Postgres
  + Auth.js v5 pivot

Gradual migration in progress
- src/lib/admin-permissions.ts still uses dev_session / rc_auth_uid;
  the admin shell will show 'Access Denied' for Auth.js-only
  sessions until each page is flipped over
- @supabase/* packages remain in package.json for the same reason
- production deployment (AUTH_URL=https://, __Secure- cookies) is
  out of scope for this pass
2026-06-06 03:40:09 +00:00
223 changed files with 10034 additions and 5589 deletions
+77 -29
View File
@@ -1,34 +1,82 @@
# --- 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
# ============================================================================
# Route Commerce — Environment variables
# ============================================================================
# Copy to `.env.local` and fill in real values for local development.
# Production: set these in your hosting dashboard (Vercel / Netlify / etc.).
# ============================================================================
# --- 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
# ── App ─────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_BASE_URL=http://localhost:4000
NEXT_PUBLIC_SITE_URL=http://localhost:4000
# --- 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
# ── Database (Postgres, direct — Supabase is being removed) ────────────────
# Single connection string used by `pg.Pool` in src/lib/auth.ts and the
# admin-permissions / data-service layer. Format:
# postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce
# --- 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
# ── Auth.js (NextAuth v5) ───────────────────────────────────────────────────
# Generate with: npx auth secret
# Or: openssl rand -base64 32
AUTH_SECRET=replace-me-with-a-32-byte-base64-string
# --- App secrets ---
# Base URL used to build OAuth callback URLs. In dev:
AUTH_URL=http://localhost:4000
# In production, set to https://yourdomain.com
# Google OAuth provider.
# 1. Go to https://console.cloud.google.com/apis/credentials
# 2. Create an OAuth 2.0 Client ID (type: Web application)
# 3. Add Authorized redirect URI: http://localhost:3000/api/auth/callback/google
# 4. Copy client id + client secret below
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Auth.js also reads AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET if you prefer the
# default NextAuth variable names. The code in src/auth.config.ts falls
# back to those names if GOOGLE_CLIENT_ID is unset.
# Set to "false" to disable the in-app dev credentials provider even in
# development. Default: enabled in dev only.
ALLOW_DEV_LOGIN=true
# ── Supabase (legacy, being removed) ────────────────────────────────────────
# Still used by the existing admin pages, server actions, and the
# `getAdminUser` flow. Once the auth migration is complete and the
# @supabase/* packages are removed, these can go away.
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# ── Stripe ─────────────────────────────────────────────────────────────────
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_PRICE_STARTER=
STRIPE_PRICE_FARM=
STRIPE_PRICE_ENTERPRISE=
STRIPE_PRICE_HARVEST_REACH=
STRIPE_PRICE_WHOLESALE_PORTAL=
STRIPE_PRICE_WATER_LOG=
STRIPE_PRICE_AI_TOOLS=
STRIPE_PRICE_SQUARE_SYNC=
STRIPE_PRICE_SMS_CAMPAIGNS=
# ── Resend (transactional email) ────────────────────────────────────────────
RESEND_API_KEY=
RESEND_WEBHOOK_SECRET=
# ── AI providers ────────────────────────────────────────────────────────────
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GOOGLE_API_KEY=
XAI_API_KEY=
MINIMAX_API_KEY=
MINIMAX_BASE_URL=
MINIMAX_BASE_URL=https://api.minimax.io/v1
# ── Square (optional) ───────────────────────────────────────────────────────
SQUARE_APP_SECRET=
SQUARE_ENVIRONMENT=sandbox
# ── Cron / automation ───────────────────────────────────────────────────────
CRON_SECRET=replace-me-with-a-random-string
-52
View File
@@ -1,52 +0,0 @@
name: Build
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: [self-hosted, linux, ubuntu-latest]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Apply fix-agents.js patch
run: node fix-agents.js
- name: Typecheck
env:
NEXT_PUBLIC_API_URL: http://localhost:3001
NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000
STORAGE_ENDPOINT: http://localhost:9000
DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce
run: npm run type-check
- name: Lint
env:
NEXT_PUBLIC_API_URL: http://localhost:3001
NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000
STORAGE_ENDPOINT: http://localhost:9000
DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce
run: npm run lint
- name: Build
env:
NEXT_PUBLIC_API_URL: http://localhost:3001
NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000
STORAGE_ENDPOINT: http://localhost:9000
DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce
run: npm run build
+133 -18
View File
@@ -28,6 +28,60 @@ jobs:
run: |
APP_DIR=/home/tyler/route-commerce
mkdir -p $APP_DIR
# Free the dev-stack port (3001) and the port the previous deploy used
# (so a new deploy can pick it back up if it's the lowest free port)
PREV_PORT=$(cat .postgrest-port 2>/dev/null || echo "")
for port in 3001 $PREV_PORT; do
if [ -n "$port" ] && ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${port}[[:space:]]"; then
echo "Port $port in use, freeing..."
fuser -k -9 $port/tcp 2>/dev/null || true
docker ps -aq --filter "publish=$port" 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true
fi
done
# Hard-stop the previous stack. Errors are NOT swallowed: if down
# fails, picking a port against a half-torn-down stack is exactly
# what produces the TOCTOU "address already in use" we keep hitting.
docker compose -f $APP_DIR/docker-compose.yml down --remove-orphans
# Belt-and-braces: anything with the postgrest name that survived.
docker ps -aq --filter "name=route_commerce_postgrest" | xargs -r docker rm -f >/dev/null 2>&1 || true
# docker-proxy sometimes leaves a listener behind for the published port.
pkill -9 -f 'docker-proxy.*3011' 2>/dev/null || true
pkill -9 -f 'docker-proxy.*3012' 2>/dev/null || true
pkill -9 -f 'docker-proxy.*3013' 2>/dev/null || true
sleep 3
# Verify the postgrest container is actually gone before we pick a port.
if docker ps -aq --filter "name=route_commerce_postgrest" | grep -q .; then
echo "ERROR: route_commerce_postgrest still running after down"
docker ps --filter "name=route_commerce_postgrest"
exit 1
fi
# Find the first free host port starting from 3011. Persist the choice
# so the Build and Deploy steps below can use the same URL.
POSTGREST_HOST_PORT=3011
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if ! ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${POSTGREST_HOST_PORT}[[:space:]]"; then
break
fi
echo "Port $POSTGREST_HOST_PORT in use, trying next... (attempt $attempt)"
POSTGREST_HOST_PORT=$((POSTGREST_HOST_PORT + 1))
if [ $POSTGREST_HOST_PORT -gt 30200 ]; then
echo "ERROR: no free port in 3011-30200 range"
exit 1
fi
sleep 1
done
echo "Using PostgREST host port: $POSTGREST_HOST_PORT"
echo "$POSTGREST_HOST_PORT" > .postgrest-port
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
export POSTGREST_HOST_PORT
# Seed config files into APP_DIR if missing (they live in the repo, not in APP_DIR)
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
[ -f $APP_DIR/docker-compose.yml ] || cp deploy/docker-compose.yml $APP_DIR/docker-compose.yml
cd $APP_DIR
[ -f .env ] || cp .env.example .env
# Append production secrets to .env (overriding .env.example defaults)
@@ -38,8 +92,12 @@ jobs:
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}"
echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT"
echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL"
} >> .env
docker compose up -d db postgrest minio minio_init
# Bring the stack up fresh — --force-recreate ensures no stale
# network/container references from prior failed attempts
docker compose up -d --force-recreate 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
@@ -55,12 +113,21 @@ jobs:
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
APP_DIR=/home/tyler/route-commerce
# Seed supabase/ into APP_DIR if missing (the deploy step copies it after, but
# we need it here for migrations)
[ -d $APP_DIR/supabase ] || cp -r supabase $APP_DIR/supabase
cd $APP_DIR
# PAGER= prevents psql from launching less/more in a non-interactive shell,
# which hangs indefinitely waiting for keypress. Batch all files into one
# connection for speed instead of one psql invocation per file.
export PAGER=
export PGPASSWORD="${POSTGRES_PASSWORD}"
PG="psql -h 127.0.0.1 -U ${POSTGRES_USER} -d ${POSTGRES_DB} --no-psqlrc -v ON_ERROR_STOP=0 -q"
$PG -f supabase/migrations/000_preflight_supabase_compat.sql || true
[ -f supabase/captured_schema.sql ] && $PG -f supabase/captured_schema.sql || true
# Concatenate all numbered migrations and run in one session
cat supabase/migrations/[0-9]*.sql | $PG
- name: Install dependencies
run: npm install
@@ -69,26 +136,49 @@ jobs:
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 }}
# Auth.js v5 (NextAuth). Fall back to Better Auth names if the
# Gitea secret hasn't been renamed yet.
AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }}
AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }}
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
# Supabase (legacy, still used by admin pages/server actions until
# the Auth.js migration is finished)
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
# Storage (MinIO / S3)
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
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
# Resend
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
# AI providers
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
# Email sender
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
run: npm run build
run: |
POSTGREST_HOST_PORT=$(cat .postgrest-port)
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
npm run build
- name: Deploy
env:
@@ -99,45 +189,70 @@ jobs:
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 }}
# Auth.js v5 (with Better Auth fallback for the secret name)
AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }}
AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }}
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
# Storage
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 }}
# PostgREST
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 }}
# Supabase (legacy)
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
# Stripe
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
# Resend
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
# AI
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
# Use the port chosen by Start Docker stack (persisted to .postgrest-port)
POSTGREST_HOST_PORT=$(cat .postgrest-port)
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
# Write env file from secrets (preserves existing .env for docker compose)
{
printf "DATABASE_URL=%s\n" "$DATABASE_URL"
printf "NEXT_PUBLIC_API_URL=%s\n" "$NEXT_PUBLIC_API_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 "AUTH_SECRET=%s\n" "$AUTH_SECRET"
printf "AUTH_URL=%s\n" "$AUTH_URL"
printf "NEXT_PUBLIC_AUTH_URL=%s\n" "$NEXT_PUBLIC_AUTH_URL"
printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID"
printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET"
printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN"
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
@@ -164,7 +279,7 @@ jobs:
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 deploy/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
+1 -17
View File
@@ -41,20 +41,4 @@ supabase/.temp/
# IDE / local config
.mcp.json
# 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/
.env*
@@ -1,250 +0,0 @@
# 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.
+59 -15
View File
@@ -2,11 +2,23 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Canonical Remote
There is exactly one remote — `origin` — pointing to the self-hosted Gitea repo:
- **URL:** `git@git.crispygoat.com:tyler/route-commerce.git`
- **Default branch:** `main`
- **Deploy:** push to `origin/main` triggers `.gitea/workflows/deploy.yml`
Do **not** add GitHub remotes. There is no `origin` on github.com and no separate "dev" repo. If you see `github.com/dzinesco/*` URLs in `.git/config`, that is stale configuration from a previous fork and should be removed (`git remote remove`).
## Project Overview
Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.
Tech stack: Next.js 16 (App Router) · Supabase (auth + Postgres + RLS) · Stripe · Square · Resend (email) · Tailwind CSS v4
Tech stack: Next.js 16 (App Router) · **Postgres** (direct — Supabase is being removed) · Auth.js (NextAuth v5, in-progress migration from bespoke cookie auth) · Stripe · Square · Resend (email) · Tailwind CSS v4
> **Direction:** Supabase is being removed in favor of a direct Postgres connection. The `supabase/` directory is kept as a path for migrations tooling only (no Supabase platform/CLI/auth). Until the Auth.js migration ships, auth still flows through the `dev_session` / `rc_auth_uid` cookies — see the Authentication section. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST.
---
@@ -21,14 +33,12 @@ npx tsc --noEmit # TypeScript check (no emit)
npx playwright test # Run E2E tests (Playwright)
```
> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL.
> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
> 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.
> The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies.
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details.
No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`).
E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config.
---
@@ -41,10 +51,24 @@ No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.
- `dev_session=brand_admin` — full access to assigned brand only
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
`src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and Supabase Auth in production. **Never import this file directly into Client Components** — use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
`src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and the legacy `rc_auth_uid` cookie in production (set by the pre-Auth.js `/api/login`) — never import this file directly into Client Components. Use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
The middleware (`src/middleware.ts`) guards `/admin/:path*` and `/login`. It auto-issues a `dev_session=platform_admin` cookie for the demo flow when no auth is present. A `clerk-auth.ts` helper exists in `src/lib/` but is currently a stub — do not depend on it.
The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary.
#### Auth.js (NextAuth v5) migration — in progress
The platform is migrating from the bespoke `dev_session` + `rc_auth_uid` cookie flow to **Auth.js (NextAuth v5)**, with Supabase as the database adapter and email/OAuth providers. While the migration is in flight:
- Do **not** add new code that depends on the `dev_session` or `rc_auth_uid` cookies — write against the Auth.js API (`auth()`, `signIn`, `signOut`, `getSession`) instead.
- New env vars: `AUTH_SECRET`, `AUTH_URL`, and provider-specific keys (`AUTH_GITHUB_ID`/`SECRET`, `AUTH_GOOGLE_ID`/`SECRET`, etc.). See `.env.example` for the full list.
- A new route handler at `src/app/api/auth/[...nextauth]/route.ts` will replace the ad-hoc `/api/login`, `/api/auth/uid`, and `/api/logout` endpoints.
- The middleware (`src/middleware.ts`) will eventually use `auth()` from NextAuth to populate the session; the existing `dev_session` auto-login branch is a temporary fallback for demos.
- `src/lib/admin-permissions.ts` will keep its public surface (`getAdminUser`, `getCurrentAdminUser`) but read the session from NextAuth internally — the `AdminUser` type does not need to change.
- `clerk-auth.ts` is being removed in favor of Auth.js; do not extend it.
- Until the migration ships, the `dev_session` and `rc_auth_uid` paths remain the source of truth — see the section above for current behavior.
### Server Actions Pattern
All database writes go through server actions in `src/actions/`. These:
@@ -55,9 +79,19 @@ All database writes go through server actions in `src/actions/`. These:
Server actions are "use server" files that export async functions. Client components import and call them directly.
### SECURITY DEFINER RPCs + Brand Scoping
### Database (Postgres, direct)
The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass RLS entirely. This means:
The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
#### Connection
- `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var.
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names.
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase.
#### SECURITY DEFINER RPCs + Brand Scoping
The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass any future RLS. This means:
- Brand isolation must be enforced at the **application layer** (in server actions), not in database policies
- Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it
@@ -182,10 +216,19 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
### Communications Module ("Harvest Reach")
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`.
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.)
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
**Scheduled automations** (declared in `vercel.json`):
- `POST /api/email-automation/abandoned-cart` — every 6h, fires abandoned-cart sequence emails
- `POST /api/email-automation/welcome-sequence` — every 6h, fires welcome onboarding sequence
- `POST /api/cron/send-scheduled` — daily 09:00, sends scheduled campaigns
- `POST /api/wholesale/notifications/{send,dispatch,pickup-reminder}` — wholesale lifecycle
- `POST /api/square/process-queue` — every 2 min, drains Square sync queue
These endpoints are also reachable via curl for manual triggering; the email-automation routes accept `Authorization: Bearer $CRON_SECRET`.
### Payments
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
@@ -200,7 +243,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Key Conventions
- All DB mutations use Supabase REST API (`fetch` to `${supabaseUrl}/rest/v1/rpc/...`) from server actions, NOT the Supabase JS client (avoids SSR cookie issues)
- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`.
- `gen_random_uuid()` used in migrations for primary keys
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
- Status enums stored as TEXT — no PostgreSQL ENUM type
@@ -217,11 +260,11 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|---|---|
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
| Middleware (route protection) | `src/middleware.ts` |
| Server actions | `src/actions/*.ts` (one file per domain) |
| Server actions | `src/actions/*.ts` (one file per domain; also grouped into `src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/`) |
| Admin pages | `src/app/admin/[module]/page.tsx` |
| Admin client components | `src/components/admin/*.tsx` |
| Migrations | `supabase/migrations/` |
| Supabase client | `src/lib/supabase.ts` |
| Migrations | `supabase/migrations/` (kept for now; will likely move to `db/migrations/` in a later pass) |
| Postgres pool / driver | `src/lib/db.ts` (TBD — create during the Supabase removal pass) |
| Email templates | `src/lib/email-templates.ts` |
| Date formatting | `src/lib/format-date.ts` |
| Feature flags | `src/lib/feature-flags.ts` |
@@ -238,7 +281,8 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Gotchas
- **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone.
- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have RLS disabled. All brand scoping must be enforced in server actions.
- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have no row-level policies. All brand scoping must be enforced in server actions.
- **Supabase residue in the wild**: `grep -r "@supabase" src/` will still find imports during the transition. Do not add new ones; if you're touching a file that imports from Supabase, replace the call with the equivalent `pg`-pool call before merging.
- **Webhook event_id**: `log_communication_messages` never populates `event_id`, so the Resend webhook uses `customer_email + subject` lookup instead.
- **Mixed fulfillment orders**: An order can have both pickup and ship items. `get_shipping_orders` RPC returns orders with at least one `fulfillment = 'ship'` item.
- **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`.
+72 -9
View File
@@ -2,11 +2,40 @@
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)
**Last updated:** 2026-06-06 (Supabase → Postgres pivot)
---
## Supabase CLI + Migrations Tooling
## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture.
### What changes immediately
- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`.
- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**).
- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth.
- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**.
### What's TBD / needs follow-up
- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet)
- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided**
- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md)
- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings?
- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition
- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally?
### Migration content that's now obsolete
- **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice.
- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world.
- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
### Historical sections below
The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
---
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)*
### Login + Link (done in this session)
- User ran `supabase login`
@@ -35,15 +64,23 @@ Key changes:
- Falls back to direct `pg` only if CLI path fails.
- Header comments updated with current recommended workflow.
**Recommended commands now:**
**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):**
```bash
# Supabase CLI path (legacy — do not use going forward)
supabase login
supabase link --project-ref wnzkhezyhnfzhkhiflrp
node supabase/push-migrations.js 148 # or any prefix
node supabase/push-migrations.js 148 # CLI path
# or
npm run migrate:one 148
```
```bash
# Direct pg path (this is the future — only the pg branch is kept alive in the script)
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
# or
DATABASE_URL=postgres://... npm run migrate:one 148
```
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
---
@@ -133,19 +170,23 @@ Verification queries (post-apply) confirmed:
---
## Current State / Gotchas
## Current State / Gotchas (2026-06-06)
- `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.
- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only.
- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.)
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase.
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`.
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch.
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
---
## How to Use This Memory
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
- **Read the Direction Pivot section first** — it supersedes the older Supabase-flavored instructions.
- Update this file with new key facts, applied migrations, or new gotchas.
- Feel free to add dated sections.
@@ -245,3 +286,25 @@ Follow-up pass on the original Codex review covering public site, buyer path, bi
### 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.
## Gitea build fix — 2026-06-06
Gitea runner (`https://git.crispygoat.com/tyler/route-commerce.git`, branch `main`) was failing `next build` with two errors:
1. **DYNAMIC_SERVER_USAGE** on `/admin/settings/square-sync` (and the whole admin tree): `getAdminUser()` reads `cookies()` via `next/headers`. The admin layout tried to prerender statically, so the first child page that hit cookies aborted the build.
2. **Prerender ECONNREFUSED** on `/indian-river-direct/stops`: `getPublicStopsForBrand` / `getActiveStopsForSitemap` / `getBrandSettingsPublic` fetch `NEXT_PUBLIC_SUPABASE_URL` at build time. The Gitea runner passes a Supabase URL that resolves but is unreachable, so `fetch` throws `ECONNREFUSED` and the prerender aborts.
The earlier commit `2f3be54 fix(actions): skip Supabase fetch at build time when env vars unset` only added `if (!supabaseUrl || !supabaseKey) return [];` — but in CI the env vars **are** set, so the guard passed and the fetch was still attempted.
### Fixes applied
- `src/actions/stops.ts` — wrapped `getActiveStopsForSitemap` and `getPublicStopsForBrand` fetches in `try/catch` returning `[]` on error. Env-var guard kept as fast path.
- `src/actions/brand-settings.ts` — wrapped `getBrandSettingsPublic` fetch in `try/catch` returning `{ success: false }` on error.
- `src/app/admin/settings/square-sync/page.tsx` — added `export const dynamic = "force-dynamic";` (was missing).
- `src/app/admin/layout.tsx` — added `export const dynamic = "force-dynamic";` so the entire admin tree opts out of static prerender (layout calls `getAdminUser()` which reads cookies).
- `.gitea/workflows/deploy.yml` was simplified earlier in commit `2d837bc` to a thin wrapper around `deploy/deploy.sh`.
### Remote
- The crispygoat repo (`git@git.crispygoat.com:tyler/route-commerce.git`) and the GitHub `origin` repo are separate forks — `tyler/main` is the self-hosted Auth.js + Postgres branch, `origin/main` is the Supabase branch. Don't merge them; they share no deploy workflow.
- Push targets `tyler/main` to trigger the Gitea build.
+1 -1
View File
@@ -1,6 +1,6 @@
# Route Commerce
A multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands manage products, stops, orders, and wholesale customers from a single admin dashboard. 🚀
A multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands manage products, stops, orders, and wholesale customers from a single admin dashboard.
## What It Does
+52
View File
@@ -0,0 +1,52 @@
# =============================================================================
# .env.production — secrets + dynamic ports for the running containers
# =============================================================================
#
# deploy.sh writes the first three lines on every successful deploy.
# Everything below is YOUR responsibility to populate. deploy.sh preserves
# unknown lines verbatim across deploys (it only overwrites the lines it
# knows about), so you can safely commit this file to a private repo or
# provision it via your secrets manager of choice.
#
# In production, this file should be mode 0600 and owned by the deploy user.
# =============================================================================
# --- managed by deploy.sh (do not edit by hand) -------------------------------
POSTGREST_HOST_PORT=3011
NEXTJS_HOST_PORT=3012
NEXT_PUBLIC_API_URL=http://localhost:3011
# --- PostgREST connection ---------------------------------------------------
PGRST_DB_URI=postgres://app:secret@db.internal:5432/app_production
PGRST_DB_ANON_ROLE=anon
PGRST_DB_SCHEMA=public
# --- Next.js server-side secrets -------------------------------------------
# Anything not prefixed NEXT_PUBLIC_ is server-only and read at request time.
DATABASE_URL=postgres://app:secret@db.internal:5432/app_production
# Auth.js v5 (NextAuth). Generate AUTH_SECRET with `npx auth secret` or
# `openssl rand -base64 32`. AUTH_URL is the public base URL the browser
# uses to build OAuth callback URLs.
AUTH_SECRET=change-me-to-a-long-random-string
AUTH_URL=https://app.example.com
NEXT_PUBLIC_AUTH_URL=https://app.example.com
ALLOW_DEV_LOGIN=false
# Google OAuth provider for Auth.js. Set both AUTH_GOOGLE_ID/SECRET and
# GOOGLE_CLIENT_ID/SECRET (the v5 code reads either name).
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
# --- External services ------------------------------------------------------
STRIPE_SECRET_KEY=sk_live_replace_me
STRIPE_PUBLISHABLE_KEY=pk_live_replace_me
STRIPE_WEBHOOK_SECRET=whsec_replace_me
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=replace_me
SMTP_FROM="My App <noreply@example.com>"
+6
View File
@@ -0,0 +1,6 @@
# Runtime artefacts written by deploy.sh — do NOT commit these.
.deploy.lock
deploy.log
.postgrest-port
.nextjs-port
.env.production
+57
View File
@@ -0,0 +1,57 @@
# =============================================================================
# Dockerfile.nextjs — multi-stage build for the Next.js frontend
# =============================================================================
# Used by docker-compose.yml's `nextjs` service.
#
# Why this looks the way it does:
# - `NEXT_PUBLIC_API_URL` must be present at BUILD time (Next.js inlines
# it into the client JS). We pass it through as an ARGs so the build
# context is reproducible (`docker build --build-arg` or via deploy.sh's
# `docker compose --env-file` flow).
# - We copy the host's pre-built `.next/` (produced by `npm run build` in
# deploy.sh) rather than running `next build` inside the image. This
# keeps the image lean and avoids double-building.
# =============================================================================
# ---- builder: produce node_modules with dev deps for the build step --------
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
# ---- builder: produce the standalone .next/ output ------------------------
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# These ARGs are wired through docker-compose's `args:` block (or the CLI).
# deploy.sh exports them in the build environment.
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ARG NEXTJS_HOST_PORT
ENV NEXTJS_HOST_PORT=${NEXTJS_HOST_PORT}
RUN npm run build
# ---- runner: minimal image, standalone server -----------------------------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Run as non-root.
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Copy only what the standalone server needs.
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
# Adjust this CMD to match the actual server file your build emits.
# For `output: "standalone"` in next.config.js the file is server.js.
CMD ["node", "server.js"]
+54
View File
@@ -0,0 +1,54 @@
# =============================================================================
# Makefile — convenience targets around deploy.sh
# =============================================================================
# All targets are wrappers; you can also invoke deploy.sh directly.
SHELL := /usr/bin/env bash
.SHELLFLAGS := -Eeu -o pipefail -c
.SHELLFLAGS_LOG := $(.SHELLFLAGS)
DEPLOY := ./deploy.sh
HEALTH := ./healthcheck.sh
WORKSPACE ?= $(CURDIR)
.PHONY: help
help: ## Show this help message
@awk 'BEGIN {FS = ":.*##"; printf "Targets:\n"} /^[a-zA-Z_-]+:.*##/ { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
.PHONY: deploy
deploy: ## Run a full deploy (build + up + nginx + healthcheck)
$(DEPLOY)
.PHONY: deploy-verbose
deploy-verbose: ## Deploy with extra logging (PRUNE_IMAGES=0, longer healthcheck)
PRUNE_IMAGES=0 HEALTHCHECK_TIMEOUT=120 $(DEPLOY)
.PHONY: health
health: ## Run a one-shot health check against the running stack
WORKSPACE=$(WORKSPACE) $(HEALTH)
.PHONY: health-nginx
health-nginx: ## Health check including the nginx-fronted URL
WORKSPACE=$(WORKSPACE) $(HEALTH) --nginx
.PHONY: status
status: ## Show current prod ports and running containers
@echo "PostgREST port: $$(cat .postgrest-port 2>/dev/null || echo none)"
@echo "Next.js port: $$(cat .nextjs-port 2>/dev/null || echo none)"
@cd deploy && docker compose -p prod-app ps
.PHONY: logs
logs: ## Tail deploy.log
tail -n 200 -f deploy.log
.PHONY: down
down: ## Stop the production stack (without redeploying)
cd deploy && docker compose -p prod-app down --remove-orphans
.PHONY: rollback
rollback: ## Restart the previous stack (the one whose ports are still on disk)
@if [[ ! -f .postgrest-port ]]; then echo "no .postgrest-port to roll back to"; exit 1; fi
cd deploy && \
POSTGREST_HOST_PORT=$$(cat ../.postgrest-port) \
NEXTJS_HOST_PORT=$$(cat ../.nextjs-port) \
docker compose -p prod-app --env-file ../.env.production up -d
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env bash
# =============================================================================
# deploy.sh — Idempotent PostgREST + Next.js production deploy
# =============================================================================
#
# Self-hosted single-server deploy. Triggered manually, by Gitea webhook, or
# by a Gitea Actions runner after a push to `main` (or `gitea-sync`).
#
# What it does, in order:
# 1. Acquires an exclusive flock (concurrent deploys die loudly).
# 2. CLEANUP: stops the dev stack on :3001 and the previous prod stack
# (port read from .postgrest-port / .nextjs-port).
# 3. PORT_SELECTION: picks the lowest free port in [3011..30200] for
# PostgREST, then the next free one for the Next.js frontend.
# 4. BUILD: runs `npm run build` with NEXT_PUBLIC_API_URL exported so it
# gets inlined into the client bundle.
# 5. DEPLOY: writes the chosen ports to .env.production, brings the
# compose stack up.
# 6. NGINX: renders the nginx config from a template (with the current
# ports), `nginx -t`s it, and reloads the host systemd nginx.
# 7. HEALTHCHECK: curls the new stack; if anything is down, rolls back.
# 8. IMAGE_PRUNE: optional, removes dangling images on success.
#
# Files written to the workspace root:
# .postgrest-port current PostgREST host port (atomic)
# .nextjs-port current Next.js host port (atomic)
# .env.production rendered env fed to docker compose
# .deploy.lock flock target
# deploy.log append-only log
# =============================================================================
set -Eeuo pipefail
IFS=$'\n\t'
# -----------------------------------------------------------------------------
# Configurable variables (override via environment before invoking)
# -----------------------------------------------------------------------------
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
COMPOSE_DIR="${COMPOSE_DIR:-${WORKSPACE}/deploy}"
COMPOSE_FILE="${COMPOSE_FILE:-${COMPOSE_DIR}/docker-compose.yml}"
NGINX_TEMPLATE="${NGINX_TEMPLATE:-${COMPOSE_DIR}/nginx.conf.template}"
NGINX_RENDERED="${NGINX_RENDERED:-/etc/nginx/sites-available/prod-app.conf}"
NGINX_LINK="${NGINX_LINK:-/etc/nginx/sites-enabled/prod-app.conf}"
NGINX_OWNER="${NGINX_OWNER:-www-data:www-data}"
PROJECT_NAME="${PROJECT_NAME:-prod-app}"
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
ENV_FILE="${ENV_FILE:-${WORKSPACE}/.env.production}"
LOCK_FILE="${LOCK_FILE:-${WORKSPACE}/.deploy.lock}"
LOG_FILE="${LOG_FILE:-${WORKSPACE}/deploy.log}"
DEV_PORT="${DEV_PORT:-3001}"
PORT_RANGE_START="${PORT_RANGE_START:-3011}"
PORT_RANGE_END="${PORT_RANGE_END:-30200}"
HEALTHCHECK_TIMEOUT="${HEALTHCHECK_TIMEOUT:-60}" # seconds total
HEALTHCHECK_INTERVAL="${HEALTHCHECK_INTERVAL:-2}" # seconds between tries
# Image pruning (set PRUNE_IMAGES=0 to skip)
PRUNE_IMAGES="${PRUNE_IMAGES:-1}"
# Optional: pin the public URL the browser uses. If empty, we default to
# http://localhost:${POSTGREST_HOST_PORT}. For production with a real domain
# and nginx in front, set e.g. NEXT_PUBLIC_API_URL=https://app.example.com/api
NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-}"
# -----------------------------------------------------------------------------
# Logging — every line is timestamped, tee'd to stdout AND the log file.
# We replace the shell's fd 1/2 with a tee so any tool that prints (npm, docker,
# curl) lands in both places automatically.
# -----------------------------------------------------------------------------
mkdir -p "$(dirname "$LOG_FILE")"
exec > >(tee -a "$LOG_FILE") 2>&1
ts() { date '+%Y-%m-%d %H:%M:%S'; }
log() { printf '[%s] %s\n' "$(ts)" "$*"; }
hr() { printf '%s\n' '----------------------------------------------------------------'; }
section() { hr; log "== $* =="; hr; }
# Trap so we always release the lock and surface a useful message.
on_exit() {
local exit_code=$?
if (( exit_code != 0 )); then
log "DEPLOY FAILED with exit code ${exit_code}"
log "See ${LOG_FILE} for full output. Rollback hints:"
log " - Previous port was: ${PREVIOUS_POSTGREST_PORT:-<unknown>}"
log " - Current .postgrest-port value: $(read_port_file "$POSTGREST_PORT_FILE" || echo '<none>')"
log " - To restart the old stack manually:"
log " POSTGREST_HOST_PORT=${PREVIOUS_POSTGREST_PORT:-3011} \\"
log " NEXTJS_HOST_PORT=${PREVIOUS_NEXTJS_PORT:-3012} \\"
log " docker compose -p ${PROJECT_NAME} --env-file ${ENV_FILE} up -d"
else
log "DEPLOY OK — PostgREST on :${NEW_POSTGREST_PORT}, Next.js on :${NEW_NEXTJS_PORT}"
fi
# flock on fd 9 releases automatically when the script exits.
}
trap on_exit EXIT
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
read_port_file() {
# Echo the port in $1, or empty string if missing/garbage.
local f="$1"
[[ -f "$f" ]] || return 1
local v
v=$(tr -d '[:space:]' < "$f" 2>/dev/null || true)
[[ "$v" =~ ^[0-9]+$ ]] || return 1
printf '%s' "$v"
}
render_template() {
# Portable envsubst: replaces $VAR and ${VAR} references in stdin with
# values from the current environment. Only the variable names given as
# args are expanded (matches `envsubst` behavior). If real envsubst is
# available we use it for speed.
local vars="$1"
if command -v envsubst >/dev/null 2>&1; then
envsubst "$vars"
else
# Build a sed expression like: s/\${VAR}/$VAR/g; s/\bVAR\b/$VAR/g
local sed_expr=()
for v in $vars; do
v="${v#\$}"
v="${v#\{}"
v="${v%\}}"
sed_expr+=( -e "s|\${${v}}|${!v:-}|g" )
sed_expr+=( -e "s|\$${v}\b|${!v:-}|g" )
done
sed "${sed_expr[@]}"
fi
}
is_listening() {
# Returns 0 if port $1 has a TCP listener (v4 or v6) on this host.
local port="$1"
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${port}$"
}
next_free_port() {
# Walk PORT_RANGE_START..PORT_RANGE_END and return the first port nobody
# is listening on. Returns 1 if none are free.
local p
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
if ! is_listening "$p"; then
printf '%s' "$p"
return 0
fi
done
return 1
}
atomic_write() {
# Write stdin to $1 atomically: write to temp, fsync, rename. This is
# what lets us use .postgrest-port as a single source of truth — readers
# always see either the old value or the new value, never a half-written one.
local target="$1"
local tmp
tmp=$(mktemp "${target}.tmp.XXXXXX")
cat > "$tmp"
sync
mv -f "$tmp" "$target"
}
free_port() {
# Try several strategies to free a port:
# 1. docker compose down for our project (idempotent)
# 2. brute-force kill of any process bound to the port
local port="$1" label="$2"
if [[ -z "$port" ]]; then return 0; fi
log " ${label} port ${port}: stopping project '${PROJECT_NAME}' (if up)"
( cd "$COMPOSE_DIR" && docker compose -p "$PROJECT_NAME" down --remove-orphans --timeout 10 ) \
>/dev/null 2>&1 || true
if is_listening "$port"; then
log " ${label} port ${port}: still listening, attempting pkill"
# fuser prints PIDs holding the port; xargs kills them.
local pids
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
if [[ -n "$pids" ]]; then
# shellcheck disable=SC2086
kill $pids 2>/dev/null || true
sleep 1
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
[[ -n "$pids" ]] && kill -9 $pids 2>/dev/null || true
fi
fi
if is_listening "$port"; then
log " ${label} port ${port}: WARNING — still in use after cleanup"
return 1
fi
log " ${label} port ${port}: free"
return 0
}
healthcheck() {
# Hit $1 (URL) until it returns 2xx within HEALTHCHECK_TIMEOUT seconds.
local url="$1" label="$2" elapsed=0
log " ${label}: ${url}"
while (( elapsed < HEALTHCHECK_TIMEOUT )); do
if curl -fsS --max-time 5 -o /dev/null "$url"; then
log " ${label}: OK (after ${elapsed}s)"
return 0
fi
sleep "$HEALTHCHECK_INTERVAL"
elapsed=$(( elapsed + HEALTHCHECK_INTERVAL ))
done
log " ${label}: FAILED after ${HEALTHCHECK_TIMEOUT}s"
return 1
}
# -----------------------------------------------------------------------------
# Lock — refuse to run if another deploy is in flight.
# -----------------------------------------------------------------------------
section "LOCK"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "Another deploy holds ${LOCK_FILE}. Exiting."
exit 1
fi
log "Acquired exclusive lock on ${LOCK_FILE}"
# -----------------------------------------------------------------------------
# 0. Banner
# -----------------------------------------------------------------------------
section "DEPLOY START"
log "Workspace: ${WORKSPACE}"
log "Project: ${PROJECT_NAME}"
log "Compose: ${COMPOSE_FILE}"
log "Nginx tpl: ${NGINX_TEMPLATE}"
log "Port range: ${PORT_RANGE_START}..${PORT_RANGE_END}"
log "Caller: ${USER:-<unknown>}@$(hostname)"
# -----------------------------------------------------------------------------
# 1. CLEANUP — port 3001 (dev) and the previous prod ports.
# -----------------------------------------------------------------------------
section "CLEANUP"
free_port "$DEV_PORT" "dev"
PREVIOUS_POSTGREST_PORT=$(read_port_file "$POSTGREST_PORT_FILE" || true)
PREVIOUS_NEXTJS_PORT=$(read_port_file "$NEXTJS_PORT_FILE" || true)
log "Previous prod ports: PostgREST=${PREVIOUS_POSTGREST_PORT:-<none>} Next.js=${PREVIOUS_NEXTJS_PORT:-<none>}"
# Stale-port guard: if the file points to a port that is NOT in our standard
# range, or to a port that nothing is listening on anymore, we still tear
# down the project (cheap) but we don't try to free the port itself —
# someone else might be using it.
free_port "${PREVIOUS_POSTGREST_PORT:-}" "prev-postgrest"
free_port "${PREVIOUS_NEXTJS_PORT:-}" "prev-nextjs"
# -----------------------------------------------------------------------------
# 2. PORT_SELECTION — find the two lowest free ports.
# -----------------------------------------------------------------------------
section "PORT_SELECTION"
NEW_POSTGREST_PORT=$(next_free_port) || {
log "No free port in [${PORT_RANGE_START}..${PORT_RANGE_END}]. Bailing out."
exit 2
}
log "PostgREST: ${NEW_POSTGREST_PORT}"
# Re-check after allocation, since we want distinct ports for both services.
NEW_NEXTJS_PORT=""
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
if (( p == NEW_POSTGREST_PORT )); then continue; fi
if ! is_listening "$p"; then NEW_NEXTJS_PORT="$p"; break; fi
done
if [[ -z "$NEW_NEXTJS_PORT" ]]; then
log "No free port for Next.js after allocating ${NEW_POSTGREST_PORT}. Bailing out."
exit 2
fi
log "Next.js: ${NEW_NEXTJS_PORT}"
# -----------------------------------------------------------------------------
# 3. BUILD — Next.js, with NEXT_PUBLIC_API_URL inlined into the client bundle.
# -----------------------------------------------------------------------------
section "BUILD"
cd "$WORKSPACE"
# Default the public API URL the browser will see.
if [[ -z "$NEXT_PUBLIC_API_URL" ]]; then
NEXT_PUBLIC_API_URL="http://localhost:${NEW_POSTGREST_PORT}"
fi
log "NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}"
# Node-only check: don't try to build if there's no package.json.
if [[ -f package.json ]]; then
# Make sure the deps are present (idempotent — npm ci is a no-op when locked).
if [[ -f package-lock.json ]]; then
log "npm ci (locked install)"
npm ci --no-audit --no-fund
else
log "npm install (no lockfile present — consider committing package-lock.json)"
npm install --no-audit --no-fund
fi
log "npm run build"
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
npm run build
else
log "No package.json in ${WORKSPACE} — skipping build step."
fi
# -----------------------------------------------------------------------------
# 4. ENV FILE — render .env.production for the running containers.
# -----------------------------------------------------------------------------
section "ENV"
# Preserve any pre-existing secrets in .env.production. We only own the lines
# we write; everything else is left alone. (The simplest sane strategy.)
SECRETS_FILE=""
if [[ -f "$ENV_FILE" ]]; then
SECRETS_FILE=$(mktemp)
# Drop any lines we manage; keep the rest verbatim.
grep -v -E '^(POSTGREST_HOST_PORT|NEXTJS_HOST_PORT|NEXT_PUBLIC_API_URL)=' \
"$ENV_FILE" > "$SECRETS_FILE" || true
fi
{
printf '# Generated by deploy.sh on %s — safe to edit, lines below are managed\n' "$(ts)"
printf 'POSTGREST_HOST_PORT=%s\n' "$NEW_POSTGREST_PORT"
printf 'NEXTJS_HOST_PORT=%s\n' "$NEW_NEXTJS_PORT"
printf 'NEXT_PUBLIC_API_URL=%q\n' "$NEXT_PUBLIC_API_URL"
if [[ -n "$SECRETS_FILE" ]]; then
cat "$SECRETS_FILE"
rm -f "$SECRETS_FILE"
fi
} > "${ENV_FILE}.new"
mv -f "${ENV_FILE}.new" "$ENV_FILE"
chmod 600 "$ENV_FILE"
log "Wrote ${ENV_FILE}"
# -----------------------------------------------------------------------------
# 5. DEPLOY — bring the stack up.
# -----------------------------------------------------------------------------
section "DEPLOY"
cd "$COMPOSE_DIR"
log "docker compose -p ${PROJECT_NAME} up -d --build"
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --build
# -----------------------------------------------------------------------------
# 6. NGINX — render config from template, test, reload.
# -----------------------------------------------------------------------------
section "NGINX"
if [[ -f "$NGINX_TEMPLATE" ]]; then
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
render_template '${POSTGREST_HOST_PORT} ${NEXTJS_HOST_PORT} ${NEXT_PUBLIC_API_URL}' \
< "$NGINX_TEMPLATE" > "$NGINX_RENDERED"
log "Rendered: ${NGINX_RENDERED}"
chown "$NGINX_OWNER" "$NGINX_RENDERED" 2>/dev/null || true
chmod 644 "$NGINX_RENDERED"
# Wire it into sites-enabled if not already linked.
if [[ ! -L "$NGINX_LINK" && ! -e "$NGINX_LINK" ]]; then
log "Enabling site: ${NGINX_LINK} -> ${NGINX_RENDERED}"
ln -s "$NGINX_RENDERED" "$NGINX_LINK"
fi
log "nginx -t"
nginx -t
log "systemctl reload nginx"
systemctl reload nginx
else
log "No nginx template at ${NGINX_TEMPLATE} — skipping reverse proxy step."
fi
# -----------------------------------------------------------------------------
# 7. HEALTHCHECK — direct + via nginx (when applicable).
# -----------------------------------------------------------------------------
section "HEALTHCHECK"
# Direct checks (bypass nginx, catch compose issues)
healthcheck "http://127.0.0.1:${NEW_POSTGREST_PORT}/" "postgrest-direct" || ROLLBACK=1
healthcheck "http://127.0.0.1:${NEW_NEXTJS_PORT}/" "nextjs-direct" || ROLLBACK=1
# nginx-fronted check (only meaningful if nginx template exists)
if [[ -f "$NGINX_TEMPLATE" && "${ROLLBACK:-0}" != "1" ]]; then
healthcheck "http://127.0.0.1/" "nginx-front" || ROLLBACK=1
fi
if [[ "${ROLLBACK:-0}" == "1" ]]; then
log "HEALTHCHECK FAILED — rolling back."
log "Tearing down the new stack on :${NEW_POSTGREST_PORT} / :${NEW_NEXTJS_PORT}"
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" down --remove-orphans --timeout 10 || true
# If we had a previous port file, the old one is still on disk (we wrote
# the new one to .new and only mv'd on success... but we DID mv already,
# so re-write the old value).
if [[ -n "${PREVIOUS_POSTGREST_PORT:-}" ]]; then
printf '%s\n' "$PREVIOUS_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
else
rm -f "$POSTGREST_PORT_FILE"
fi
if [[ -n "${PREVIOUS_NEXTJS_PORT:-}" ]]; then
printf '%s\n' "$PREVIOUS_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
else
rm -f "$NEXTJS_PORT_FILE"
fi
exit 3
fi
# -----------------------------------------------------------------------------
# 8. PERSIST — commit the chosen ports as the new single source of truth.
# (Done AFTER healthcheck so a failed deploy doesn't clobber the old one.)
# -----------------------------------------------------------------------------
section "PERSIST"
printf '%s\n' "$NEW_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
printf '%s\n' "$NEW_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
log ".postgrest-port = ${NEW_POSTGREST_PORT}"
log ".nextjs-port = ${NEW_NEXTJS_PORT}"
# -----------------------------------------------------------------------------
# 9. IMAGE_PRUNE — optional housekeeping.
# -----------------------------------------------------------------------------
if [[ "$PRUNE_IMAGES" == "1" ]]; then
section "IMAGE_PRUNE"
docker image prune -f
fi
section "DONE"
exit 0
+70
View File
@@ -0,0 +1,70 @@
# =============================================================================
# docker-compose.yml — production stack consumed by deploy.sh
# =============================================================================
#
# The host-side ports (POSTGREST_HOST_PORT, NEXTJS_HOST_PORT) are written by
# deploy.sh into .env.production. We interpolate from there with ${VAR:-3011}
# so a manual `docker compose up` without the deploy script still works.
#
# Note on networking: the Next.js container calls PostgREST on
# `host.docker.internal:POSTGREST_HOST_PORT` so the inlined
# NEXT_PUBLIC_API_URL (a localhost URL, per the deploy contract) resolves
# correctly. On Linux you may need to add
# extra_hosts:
# - "host.docker.internal:host-gateway"
# which is included below for that reason.
# =============================================================================
name: prod-app # default project name; deploy.sh overrides with -p
services:
postgrest:
image: postgrest/postgrest:latest
container_name: prod-app-postgrest
restart: unless-stopped
# The host port is dynamic. The container always listens on 3000.
ports:
- "${POSTGREST_HOST_PORT:-3011}:3000"
environment:
PGRST_DB_URI: ${PGRST_DB_URI}
PGRST_DB_ANON_ROLE: ${PGRST_DB_ANON_ROLE:-anon}
PGRST_DB_SCHEMA: ${PGRST_DB_SCHEMA:-public}
PGRST_SERVER_PORT: 3000
# Optional: tighten CORS for your real domain
PGRST_DB_TXN_END: "commit-allow-overwrite"
# Healthcheck lets `docker compose ps` show healthy state.
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
interval: 10s
timeout: 3s
retries: 6
nextjs:
# Build context is the workspace root (one level up from this file).
build:
context: ..
dockerfile: deploy/Dockerfile.nextjs
container_name: prod-app-nextjs
restart: unless-stopped
ports:
- "${NEXTJS_HOST_PORT:-3012}:3000"
environment:
# Runtime vars — these can change without rebuilding. NEXT_PUBLIC_*
# is also exported here for completeness, but the BROWSER's view of
# NEXT_PUBLIC_API_URL is baked in at build time (see Dockerfile).
NODE_ENV: production
PORT: 3000
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
env_file:
- ../.env.production # server-side secrets read at runtime
extra_hosts:
# Lets the container reach the host on the dynamically allocated port.
- "host.docker.internal:host-gateway"
depends_on:
postgrest:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
interval: 10s
timeout: 3s
retries: 6
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# =============================================================================
# healthcheck.sh — standalone, callable from cron / monitoring
# =============================================================================
#
# Reads the current prod ports from .postgrest-port / .nextjs-port and curls
# each service. Exit code is the count of failed checks (0 = all healthy).
#
# Usage:
# ./healthcheck.sh
# ./healthcheck.sh --nginx # also check the fronted URL
# WORKSPACE=/srv/app ./healthcheck.sh
# =============================================================================
set -Eeuo pipefail
IFS=$'\n\t'
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
TIMEOUT="${HEALTHCHECK_TIMEOUT:-5}"
failures=0
check() {
local label="$1" url="$2"
if curl -fsS --max-time "$TIMEOUT" -o /dev/null "$url"; then
printf ' [ OK ] %-20s %s\n' "$label" "$url"
else
printf ' [FAIL] %-20s %s\n' "$label" "$url"
failures=$(( failures + 1 ))
fi
}
pgrest_port=$(tr -d '[:space:]' < "$POSTGREST_PORT_FILE" 2>/dev/null || echo "")
next_port=$(tr -d '[:space:]' < "$NEXTJS_PORT_FILE" 2>/dev/null || echo "")
if [[ -n "$pgrest_port" ]]; then
check "postgrest" "http://127.0.0.1:${pgrest_port}/"
else
printf ' [SKIP] postgrest (no .postgrest-port)\n'
fi
if [[ -n "$next_port" ]]; then
check "nextjs" "http://127.0.0.1:${next_port}/"
else
printf ' [SKIP] nextjs (no .nextjs-port)\n'
fi
if [[ "${1:-}" == "--nginx" ]]; then
check "nginx" "http://127.0.0.1/"
fi
exit "$failures"
+89
View File
@@ -0,0 +1,89 @@
# =============================================================================
# nginx.conf.template — rendered by deploy.sh on every deploy
# =============================================================================
#
# Variables substituted by `envsubst`:
# ${POSTGREST_HOST_PORT} dynamic host port of the PostgREST container
# ${NEXTJS_HOST_PORT} dynamic host port of the Next.js container
# ${NEXT_PUBLIC_API_URL} (informational only — used in comment header)
#
# Layout:
# /api/* -> http://127.0.0.1:${POSTGREST_HOST_PORT}
# /* -> http://127.0.0.1:${NEXTJS_HOST_PORT}
#
# Tested against nginx >= 1.18 (Debian 11 / Ubuntu 22.04). Adjust ssl_*
# lines if you don't have a cert yet — deploy.sh only tests/renders, the
# operator decides whether to terminate TLS here.
# =============================================================================
# --- upstream definitions ---------------------------------------------------
upstream postgrest_upstream {
server 127.0.0.1:${POSTGREST_HOST_PORT};
keepalive 16;
}
upstream nextjs_upstream {
server 127.0.0.1:${NEXTJS_HOST_PORT};
keepalive 16;
}
# --- HTTP -> HTTPS upgrade (optional; remove if you only run on LAN) --------
server {
listen 80;
listen [::]:80;
server_name _;
# ACME http-01 challenge needs to be served on port 80.
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
# Redirect everything else to HTTPS. Comment out for plain-HTTP dev.
location / {
return 301 https://$host$request_uri;
}
}
# --- main server block ------------------------------------------------------
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name _;
# --- TLS (uncomment + adjust after you obtain a cert) ------------------
# ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# --- sensible defaults ------------------------------------------------
client_max_body_size 25m;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Connection "";
# --- API: /api/* -> PostgREST ----------------------------------------
location /api/ {
proxy_pass http://postgrest_upstream;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
# PostgREST exposes its OpenAPI spec at the root of the API; expose it
# under a stable URL too.
location = /api {
proxy_pass http://postgrest_upstream;
}
# --- everything else -> Next.js --------------------------------------
location / {
proxy_pass http://nextjs_upstream;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
}
-83
View File
@@ -1,83 +0,0 @@
services:
db:
image: postgres:16-alpine
container_name: route_commerce_db
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- db_data:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
postgrest:
image: postgrest/postgrest:v12.2.3
container_name: route_commerce_postgrest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PGRST_DB_URI: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
PGRST_DB_SCHEMA: public
PGRST_DB_ANON_ROLE: ${POSTGRES_USER}
PGRST_SERVER_PORT: 3001
PGRST_SERVER_HOST: 0.0.0.0
PGRST_OPENAPI_MODE: disabled
PGRST_DB_EXTRA_SEARCH_PATH: public,extensions
ports:
- "127.0.0.1:3001:3001"
minio:
image: minio/minio:latest
container_name: route_commerce_minio
restart: unless-stopped
env_file:
- .env
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 5
minio_init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
env_file:
- .env
entrypoint: ["/bin/sh", "-c"]
command:
- |
mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}
for b in brand-logos product-images contacts-imports videos water-photos; do
mc mb --ignore-existing local/$${b}
mc anonymous set download local/$${b}
done
profiles: ["init"]
volumes:
db_data:
driver: local
minio_data:
driver: local
-312
View File
@@ -1,312 +0,0 @@
# 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.
File diff suppressed because it is too large Load Diff
@@ -1,325 +0,0 @@
# 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.
-58
View File
@@ -1,58 +0,0 @@
import { NextResponse, type NextRequest } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000";
export async function middleware(request: NextRequest) {
const response = NextResponse.next({ request });
// ── Dev session bypass (enabled in all envs for demo) ──────────────
// Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
const devSession = request.cookies.get("dev_session")?.value;
const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee";
// 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) {
authed = true;
} else if (hasSession) {
authed = true;
}
const isAdmin = request.nextUrl.pathname.startsWith("/admin");
const isLogin = request.nextUrl.pathname === "/login";
if (isAdmin && !authed) {
// Auto-login for demo: no auth cookie present
const url = request.nextUrl.clone();
url.pathname = "/admin";
url.searchParams.set("demo", "1");
const response = NextResponse.redirect(url);
response.cookies.set("dev_session", "platform_admin", {
path: "/",
maxAge: 60 * 60 * 24,
httpOnly: true,
sameSite: "strict",
});
return response;
}
if (isLogin && authed) {
const url = request.nextUrl.clone();
url.pathname = "/admin";
return NextResponse.redirect(url);
}
return response;
}
export const config = {
matcher: [
"/admin/:path*",
"/admin",
"/login",
],
};
+1 -29
View File
@@ -19,24 +19,6 @@ const nextConfig: NextConfig = {
protocol: "https",
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"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
@@ -106,18 +88,8 @@ const nextConfig: NextConfig = {
// Rewrites for API proxy
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 [
{
source: "/storage/:path*",
destination: `${storageBase}/:path*`,
},
// Add any necessary rewrites here
];
},
+8 -6
View File
@@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0",
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint",
@@ -15,21 +15,23 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.96.0",
"@aws-sdk/client-s3": "^3.1062.0",
"@aws-sdk/s3-request-presigner": "^3.1062.0",
"@auth/pg-adapter": "^1.11.2",
"@clerk/nextjs": "^7.4.2",
"@google/generative-ai": "^0.24.1",
"@gsap/react": "^2.1.2",
"@sentry/nextjs": "^10.55.0",
"@stripe/react-stripe-js": "^3.10.0",
"@stripe/stripe-js": "^5.10.0",
"@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.105.3",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0",
"better-auth": "^1.6.14",
"exceljs": "^4.4.0",
"framer-motion": "^12.40.0",
"gsap": "^3.15.0",
"kysely": "^0.29.2",
"lucide-react": "^1.17.0",
"next": "^16.2.6",
"next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6",
"openai": "^6.37.0",
"papaparse": "^5.5.3",
@@ -61,7 +63,7 @@
"pg": "^8.20.0",
"playwright": "^1.59.1",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5.9.3"
},
"overrides": "{}"
}
+14 -3
View File
@@ -1,6 +1,15 @@
"use server";
import { svcRpc } from "@/lib/svc-fetch";
import { createClient as createServiceClient } from "@supabase/supabase-js";
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 = {
action_type: "create" | "update" | "delete";
@@ -21,7 +30,8 @@ type UserActivityPayload = {
export async function logAdminAction(payload: AdminActionPayload): Promise<void> {
try {
await svcRpc("log_admin_action", {
const service = getServiceClient();
await service.rpc("log_admin_action", {
p_payload: {
action_type: payload.action_type,
admin_id: payload.admin_id ?? null,
@@ -38,7 +48,8 @@ export async function logAdminAction(payload: AdminActionPayload): Promise<void>
export async function logUserActivity(payload: UserActivityPayload): Promise<void> {
try {
await svcRpc("log_user_activity", {
const service = getServiceClient();
await service.rpc("log_user_activity", {
p_payload: {
user_id: payload.user_id,
activity_type: payload.activity_type,
+53 -31
View File
@@ -1,41 +1,63 @@
"use server";
import { Pool } from "pg";
import { randomUUID } from "crypto";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> {
try {
// Upsert dev platform_admin record
const res = await pool.query(
`INSERT INTO admin_users (
user_id, brand_id, role, active,
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log,
can_manage_reports, can_manage_settings, must_change_password
) VALUES (
$1, NULL, 'platform_admin', true,
true, true, true, true,
true, true, true, true,
true, true, false
)
ON CONFLICT (user_id) DO UPDATE SET active = EXCLUDED.active
RETURNING id, role`,
[DEV_ADMIN_UID]
);
const cookieStore = await cookies();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
if (res.rows.length === 0) {
return { success: false, error: "Failed to upsert dev admin" };
const response = NextResponse.next();
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() { return cookieStore.getAll(); },
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
Object.entries(headers).forEach(([key, value]) => {
response.headers.set(key, value);
});
},
},
});
// Upsert dev platform_admin record
const { data: existing } = await supabase
.from("admin_users")
.select("id, role")
.eq("user_id", DEV_ADMIN_UID)
.single();
if (!existing) {
const { error: insertError } = await supabase
.from("admin_users")
.insert({
user_id: DEV_ADMIN_UID,
brand_id: null,
role: "platform_admin",
active: true,
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: true,
can_manage_water_log: true,
can_manage_reports: true,
can_manage_settings: true,
must_change_password: false,
});
if (insertError) {
return { success: false, error: insertError.message };
}
return { success: true, uid: DEV_ADMIN_UID };
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
return { success: false, error: message };
}
return { success: true, uid: DEV_ADMIN_UID };
}
+7 -2
View File
@@ -1,7 +1,7 @@
"use server";
import { cookies } from "next/headers";
import { svcRpc } from "@/lib/svc-fetch";
import { createClient as createServiceClient } from "@supabase/supabase-js";
export async function updatePasswordAction(
newPassword: string
@@ -15,7 +15,12 @@ export async function updatePasswordAction(
return { error: "Not authenticated. Please log in again." };
}
const { error } = await svcRpc("update_user_password", {
const service = createServiceClient(
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_password: newPassword,
});
-31
View File
@@ -1,31 +0,0 @@
"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 };
}
}
+21 -7
View File
@@ -1,6 +1,15 @@
"use server";
import * as authAdmin from "@/lib/auth-admin";
import { createClient as createServiceClient } from "@supabase/supabase-js";
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 =
| { success: true; tempPassword: string }
@@ -15,19 +24,24 @@ export async function resetAdminPassword(
email: string,
newPassword: string
): Promise<ResetAdminPasswordResult> {
const service = getServiceClient();
// Look up auth user by email
const { data: users, error: listError } = await authAdmin.listUsers({ searchValue: email });
if (listError) {
return { success: false, error: "Could not list users: " + listError.message };
const { data: authUsers, error: listError } = await service.auth.admin.listUsers();
if (listError || !authUsers?.users) {
return { success: false, error: "Could not list users: " + listError?.message };
}
const authUser = (users ?? []).find((u) => u.email?.toLowerCase() === email.toLowerCase());
const authUser = authUsers.users.find((u) => u.email?.toLowerCase() === email.toLowerCase());
if (!authUser) {
return { success: false, error: "No auth user found for that email address." };
}
// Update password via the better-auth admin wrapper (uses `update_user_password` RPC internally)
const { error: updateError } = await authAdmin.setUserPassword(authUser.id, newPassword);
// Update password via service role
const { error: updateError } = await service.auth.admin.updateUserById(
authUser.id,
{ password: newPassword, email_confirm: true }
);
if (updateError) {
return { success: false, error: updateError.message };
+205 -95
View File
@@ -1,10 +1,14 @@
"use server";
import { cookies, headers } from "next/headers";
import { api as publicApi } from "@/lib/api";
import { auth } from "@/lib/auth";
import { svcApi, svcRpc } from "@/lib/svc-fetch";
import * as authAdmin from "@/lib/auth-admin";
import { createServerClient } from "@supabase/ssr";
import { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { createClient as createServiceClient } from "@supabase/supabase-js";
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 = {
id: string;
@@ -71,44 +75,66 @@ export type UpdateAdminUserInput = {
phone_number?: string | null;
};
// ─── Auth helper: validate session via better-auth ──────────────────────────
// ─── SSR client for authenticated requests ─────────────────────────────────
/**
* 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> {
async function getAuthClient() {
const cookieStore = await cookies();
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 allCookies = cookieHeader.split(";").map(c => c.trim());
const rcUidCookie = allCookies.find(c => c.startsWith("rc_auth_uid="));
return rcUidCookie ? rcUidCookie.split("=")[1] : null;
const rcAuthUid = 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 }> {
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 rcAuthUid = await readRcAuthUid();
if (rcAuthUid === DEV_FORCE_UID) {
return { data: null, error: null }; // dev force-login bypass
return { data: null, error: null }; // let the action proceed without auth check
}
const headerStore = await headers();
const session = await auth.api.getSession({ headers: headerStore });
if (!session?.user) {
const { data: userData, error: userError } = await supabase.auth.getUser();
if (userError || !userData.user) {
return { data: null, error: "Not authenticated" };
}
const { data, error } = await svcRpc<T>(fn, params);
const { data, error } = await supabase.rpc(fn, params as Record<string, unknown>);
if (error) { /* RPC error handled silently */ }
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 ──
async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
@@ -121,21 +147,27 @@ async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user:
return { user: null, error: "Not authenticated" };
}
// Create auth user with the provided password (better-auth signUpEmail)
const { data: authUser, error: authError } = await authAdmin.createUser({
const service = getServiceClient();
// Create auth user with the provided password
const { data: authUser, error: authError } = await service.auth.admin.createUser({
email: input.email,
password: input.password,
name: input.display_name || input.email.split("@")[0],
email_confirm: true,
user_metadata: {
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
},
});
if (authError || !authUser) {
if (authError || !authUser.user) {
return { user: null, error: authError?.message ?? "Failed to create auth user" };
}
// Insert into admin_users (service role bypasses RLS)
const { data: inserted, error: insertError } = await svcApi
// Insert into admin_users
const { data: inserted, error: insertError } = await service
.from("admin_users")
.insert({
user_id: authUser.id,
user_id: authUser.user.id,
role: input.role,
brand_id: input.brand_id,
display_name: input.display_name || input.email.split("@")[0],
@@ -153,14 +185,11 @@ async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user:
must_change_password: input.mustChangePassword ?? true,
})
.select()
.single() as { data: any; error: { message: string } | null };
.single();
if (insertError) {
return { user: null, error: insertError.message };
}
if (!inserted) {
return { user: null, error: "Insert returned no row" };
}
// Send welcome email
try {
@@ -234,24 +263,27 @@ function mapUserRow(row: Record<string, unknown>): AdminUserRow {
// ─── Dev path helpers (service role, local only) ───────────────────────────
async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
const service = getServiceClient();
// Ensure caller has an admin_users record
if (callerUid) {
const { data: existing } = await svcApi
const { data: existing } = await service
.from("admin_users")
.select("id")
.eq("user_id", callerUid)
.maybeSingle() as { data: any };
.maybeSingle();
if (!existing) {
// auto-creating admin_users for uid
const { data: listData } = await authAdmin.listUsers({ searchValue: undefined });
const authUser = (listData ?? []).find((u: any) => u.id === callerUid);
await svcApi.from("admin_users").insert({
const { data: authData } = await service.auth.admin.listUsers();
const authUser = authData?.users?.find((u) => u.id === callerUid);
const meta = (authUser as { user_metadata?: Record<string, unknown> })?.user_metadata;
await service.from("admin_users").insert({
user_id: callerUid,
role: "platform_admin",
brand_id: null,
display_name: authUser?.name ?? authUser?.email?.split("@")[0] ?? "Admin",
phone_number: null,
display_name: (meta?.display_name as string | null) ?? authUser?.email?.split("@")[0] ?? "Admin",
phone_number: (meta?.phone_number as string | null) ?? null,
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
@@ -268,7 +300,7 @@ async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUser
}
// Fetch all admin_users rows (no RLS for service role)
const { data: adminRows, error: adminError } = await svcApi
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,
@@ -276,30 +308,57 @@ async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUser
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports,
brands (name)
`)
.order("created_at", { ascending: false }) as { data: any; error: any };
.order("created_at", { ascending: false });
if (adminError) return { users: [], error: adminError.message };
// Fetch auth user details via better-auth admin list
const { data: listData, error: authError } = await authAdmin.listUsers({ searchValue: undefined });
// Fetch auth user details via service role admin API
const { data: authData, error: authError } = await service.auth.admin.listUsers();
if (authError) return { users: [], error: authError.message };
const authMap: Record<string, { email: string; display_name: string | null }> = {};
(listData ?? []).forEach((u: any) => {
authMap[u.id] = {
email: u.email ?? "",
display_name: u.name ?? null,
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) ?? (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: any) => {
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 };
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: (r.phone_number as string | null) ?? 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] = {
email: u.email ?? "",
display_name: (u.user_metadata?.display_name as string | null) ?? (u.user_metadata?.full_name as string | null) ?? null,
phone_number: (u.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,
};
});
@@ -312,6 +371,15 @@ async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUser
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
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 headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value;
@@ -322,7 +390,7 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
.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.
// This includes real Supabase auth users — bypassing api.auth.getUser JWT validation.
// This includes real Supabase auth users — bypassing supabase.auth.getUser JWT validation.
if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) {
return devListAdminUsers(rcAuthUid);
}
@@ -338,8 +406,38 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
// 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 });
if (result.error === "Not authenticated" && rcAuthUid) {
// No better-auth session token in browser — use service role via devListAdminUsers
return devListAdminUsers(rcAuthUid);
// No Supabase session token in browser — use service role with rc_auth_uid
const service = getServiceClient();
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 };
}
@@ -368,21 +466,27 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
// 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
if (rcAuthUid) {
// Create auth user via better-auth
const { data: authUser, error: authError } = await authAdmin.createUser({
const service = getServiceClient();
// Create auth user
const { data: authUser, error: authError } = await service.auth.admin.createUser({
email: input.email,
password: input.password,
name: input.display_name || input.email.split("@")[0],
email_confirm: true,
user_metadata: {
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
},
});
if (authError || !authUser) {
if (authError || !authUser.user) {
return { user: null, error: authError?.message ?? "Failed to create auth user" };
}
// Insert into admin_users via service-role PostgREST
const { data: inserted, error: insertError } = await svcApi
// Insert into admin_users
const { data: inserted, error: insertError } = await service
.from("admin_users")
.insert({
user_id: authUser.id,
user_id: authUser.user.id,
role: input.role,
brand_id: input.brand_id,
display_name: input.display_name || input.email.split("@")[0],
@@ -402,9 +506,7 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
.select()
.single();
if (insertError || !inserted) {
return { user: null, error: insertError?.message ?? "Insert returned no row" };
}
if (insertError) return { user: null, error: insertError.message };
// Send welcome email
try {
@@ -423,26 +525,26 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
return {
user: {
id: inserted.id ?? "",
user_id: inserted.user_id ?? authUser.id,
id: inserted.id,
user_id: inserted.user_id,
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
email: input.email,
phone_number: inserted.phone_number ?? input.phone_number ?? null,
role: (inserted.role as AdminUserRow["role"]) ?? "store_employee",
brand_id: inserted.brand_id ?? null,
role: inserted.role,
brand_id: inserted.brand_id,
brand_name: null,
can_manage_products: inserted.can_manage_products ?? false,
can_manage_stops: inserted.can_manage_stops ?? false,
can_manage_orders: inserted.can_manage_orders ?? false,
can_manage_pickup: inserted.can_manage_pickup ?? false,
can_manage_messages: inserted.can_manage_messages ?? false,
can_manage_refunds: inserted.can_manage_refunds ?? false,
can_manage_users: inserted.can_manage_users ?? false,
can_manage_water_log: inserted.can_manage_water_log ?? false,
can_manage_reports: inserted.can_manage_reports ?? false,
active: inserted.active ?? true,
can_manage_products: inserted.can_manage_products,
can_manage_stops: inserted.can_manage_stops,
can_manage_orders: inserted.can_manage_orders,
can_manage_pickup: inserted.can_manage_pickup,
can_manage_messages: inserted.can_manage_messages,
can_manage_refunds: inserted.can_manage_refunds,
can_manage_users: inserted.can_manage_users,
can_manage_water_log: inserted.can_manage_water_log,
can_manage_reports: inserted.can_manage_reports,
active: inserted.active,
must_change_password: inserted.must_change_password ?? true,
created_at: inserted.created_at ?? new Date().toISOString(),
created_at: inserted.created_at,
last_login: null,
},
error: null,
@@ -462,7 +564,8 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
if (rcAuthUid === DEV_FORCE_UID) {
const { data, error } = await svcApi
const service = getServiceClient();
const { data, error } = await service
.from("admin_users")
.update({
role: input.role ?? undefined,
@@ -483,8 +586,8 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us
.eq("id", input.id)
.select()
.single();
if (error || !data) return { user: null, error: error?.message ?? "Update returned no row" };
return { user: mapUserRow(data as Record<string, unknown>), error: null };
if (error) return { user: null, error: error.message };
return { user: mapUserRow(data), error: null };
}
const result = await callRpcWithAuth<AdminUserRow[]>("update_admin_user", {
@@ -510,19 +613,20 @@ export async function deleteAdminUser(id: string): Promise<{ success: boolean; e
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
if (rcAuthUid === DEV_FORCE_UID) {
const service = getServiceClient();
// Get user_id first
const { data: adminRow, error: fetchError } = await svcApi
const { data: adminRow, error: fetchError } = await service
.from("admin_users")
.select("user_id")
.eq("id", id)
.single();
if (fetchError) return { success: false, error: fetchError.message };
// Delete from admin_users
const { error: deleteError } = await svcApi.from("admin_users").delete().eq("id", id);
const { error: deleteError } = await service.from("admin_users").delete().eq("id", id);
if (deleteError) return { success: false, error: deleteError.message };
// Delete auth user via better-auth admin
// Delete auth user
if (adminRow?.user_id) {
await authAdmin.removeUser(adminRow.user_id);
await service.auth.admin.deleteUser(adminRow.user_id);
}
return { success: true, error: null };
}
@@ -539,24 +643,30 @@ export async function setMustChangePassword(userId: string): Promise<{ success:
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
if (rcAuthUid === DEV_FORCE_UID || process.env.NODE_ENV !== "production") {
const { error } = await svcApi.from("admin_users").update({ must_change_password: true }).eq("id", userId);
const service = getServiceClient();
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
return { success: !error, error: error?.message ?? null };
}
// Production path — use service role via direct update
const { error } = await svcApi.from("admin_users").update({ must_change_password: true }).eq("id", userId);
const service = getServiceClient();
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
return { success: !error, error: error?.message ?? null };
}
export async function sendPasswordResetEmail(email: string): Promise<{ success: boolean; error: string | null }> {
const { error } = await authAdmin.requestPasswordReset({
email,
const { error } = await publicSupabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`,
});
return { success: !error, error: error?.message ?? null };
}
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
const { data, error } = await publicApi.from("brands").select("id, name").order("name");
if (useMockData) {
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 };
}
+3 -3
View File
@@ -1,6 +1,6 @@
"use server";
import { api } from "@/lib/api";
import { supabase } from "@/lib/supabase";
export type AIAuthConfig = {
provider: string;
@@ -18,7 +18,7 @@ export async function getAIPreferences(brandId: string): Promise<{
model?: string;
max_tokens?: number;
} | null> {
const { data, error } = await api
const { data, error } = await supabase
.from("brand_ai_settings")
.select("api_key, organization_id, base_url, model, max_tokens")
.eq("brand_id", brandId)
@@ -32,7 +32,7 @@ export async function saveAIPreferences(
brandId: string,
config: AIAuthConfig
): Promise<{ success: boolean; error?: string }> {
const { error } = await api
const { error } = await supabase
.from("brand_ai_settings")
.upsert({
brand_id: brandId,
+2 -2
View File
@@ -4,8 +4,8 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Types ────────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -27,8 +27,8 @@ type AuditResult =
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
*/
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const adminUser = await getAdminUser();
+56
View File
@@ -0,0 +1,56 @@
"use server";
import { signIn, signOut } from "@/lib/auth";
import { AuthError } from "next-auth";
/**
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
* use from client components.
*
* Why server actions?
* • The Auth.js v5 `signIn` function has to run on the server (it
* needs to set the session cookie, talk to the database adapter,
* and redirect the user to the OAuth provider).
* • Calling it from a client component via a server action keeps the
* client bundle small and avoids exposing the OAuth client secret.
*
* Usage from a client component:
* <form action={signInWithGoogle}>
* <button type="submit">Sign in with Google</button>
* </form>
*
* Usage for the dev credentials provider (dev only):
* <form action={signInWithDev}>
* <input name="username" />
* <input name="password" type="password" />
* <button type="submit">Dev login</button>
* </form>
*/
export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" });
}
export async function signInWithDev(formData: FormData): Promise<void> {
const username = String(formData.get("username") ?? "admin");
const password = String(formData.get("password") ?? "dev");
try {
await signIn("dev-login", {
username,
password,
redirectTo: "/admin",
});
} catch (e) {
// signIn() throws a `NEXT_REDIRECT` to navigate — let that through
// so the redirect actually happens. Re-throw any other error so the
// caller can render a meaningful message.
if (e instanceof AuthError) {
throw new Error(`Dev sign-in failed: ${e.type}`);
}
throw e;
}
}
export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" });
}
+2 -2
View File
@@ -39,8 +39,8 @@ 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!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
export type BillingSubscriptionStatus =
| "active"
+2 -2
View File
@@ -32,8 +32,8 @@ export async function createRetailStripeCheckoutSession(
}));
// Get brand name for Stripe metadata
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
let brandName = "Route Commerce";
try {
const brandRes = await fetch(
+4 -4
View File
@@ -43,8 +43,8 @@ export async function createStripeCheckoutSession(
const priceId = getPriceId(priceKey);
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_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get brand's Stripe customer ID
const custRes = await fetch(
@@ -123,8 +123,8 @@ export async function cancelAddonSubscription(
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) return { success: false, error: "Stripe not configured" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get active subscription for this brand
const subRes = await fetch(
+12 -12
View File
@@ -13,8 +13,8 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) return { success: false, error: "Stripe not configured" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get stripe_customer_id from brands table
const custRes = await fetch(
@@ -49,8 +49,8 @@ export async function updateBrandPlanTier(brandId: string, planTier: string): Pr
return { success: false, error: "Invalid plan tier" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
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/rpc/update_brand_plan_tier`,
@@ -72,8 +72,8 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
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/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 }> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
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/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>> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
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/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[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
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/rpc/get_wholesale_orders`,
+86 -68
View File
@@ -2,7 +2,6 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { uploadFile, BUCKETS, publicUrl, storageKeys } from "@/lib/storage";
export type UploadLogoResult =
| { success: true; logoUrl: string }
@@ -31,28 +30,31 @@ export async function uploadBrandLogo(
}
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
const fileName = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
const key = storageKeys.brandLogo(brandId, fileName);
const path = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
const storagePath = `brand-logos/${brandId}/${path}`;
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
let url: string;
try {
const res = await uploadFile({
bucket: BUCKETS.BRAND_LOGOS,
key,
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{
method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
body: buffer,
contentType: file.type,
});
url = res.url;
} catch (e) {
return { success: false, error: `Upload failed: ${(e as Error).message}` };
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
// Save URL to brand_settings via RPC
const saveRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
{
@@ -60,7 +62,7 @@ export async function uploadBrandLogo(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
[isDark ? "p_logo_url_dark" : "p_logo_url"]: url,
[isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
}),
}
);
@@ -69,7 +71,7 @@ export async function uploadBrandLogo(
return { success: false, error: "Upload succeeded but failed to save URL" };
}
return { success: true, logoUrl: url };
return { success: true, logoUrl: publicUrl };
}
export async function uploadOlatheSweetLogo(
@@ -94,26 +96,28 @@ export async function uploadOlatheSweetLogo(
}
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
const key = storageKeys.brandLogo(brandId, `olathe-sweet-logo.${ext}`);
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`;
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
let url: string;
try {
const res = await uploadFile({
bucket: BUCKETS.BRAND_LOGOS,
key,
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{
method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
body: buffer,
contentType: file.type,
});
url = res.url;
} catch (e) {
return { success: false, error: `Upload failed: ${(e as Error).message}` };
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
const saveRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
@@ -122,7 +126,7 @@ export async function uploadOlatheSweetLogo(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_olathe_sweet_logo_url: url,
p_olathe_sweet_logo_url: publicUrl,
}),
}
);
@@ -131,7 +135,7 @@ export async function uploadOlatheSweetLogo(
return { success: false, error: "Upload succeeded but failed to save URL" };
}
return { success: true, logoUrl: url };
return { success: true, logoUrl: publicUrl };
}
export async function uploadOlatheSweetLogoDark(
@@ -156,26 +160,28 @@ export async function uploadOlatheSweetLogoDark(
}
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
const key = storageKeys.brandLogo(brandId, `olathe-sweet-logo-dark.${ext}`);
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`;
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
let url: string;
try {
const res = await uploadFile({
bucket: BUCKETS.BRAND_LOGOS,
key,
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{
method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
body: buffer,
contentType: file.type,
});
url = res.url;
} catch (e) {
return { success: false, error: `Upload failed: ${(e as Error).message}` };
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
const saveRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
@@ -184,7 +190,7 @@ export async function uploadOlatheSweetLogoDark(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_olathe_sweet_logo_url_dark: url,
p_olathe_sweet_logo_url_dark: publicUrl,
}),
}
);
@@ -193,7 +199,7 @@ export async function uploadOlatheSweetLogoDark(
return { success: false, error: "Upload succeeded but failed to save URL" };
}
return { success: true, logoUrl: url };
return { success: true, logoUrl: publicUrl };
}
export type BrandSettings = {
@@ -246,8 +252,8 @@ export type SaveBrandSettingsResult =
| { success: false; error: string };
export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings`,
@@ -265,25 +271,37 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
// Public version for storefront pages — uses slug, no auth required
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Return
// a not-configured result; the page falls back to slug-based defaults.
if (!supabaseUrl || !supabaseKey) {
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
}
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
}
);
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just falls back to its
// default brand name and revalidates from a real request later.
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
}
);
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
const data = await response.json();
return {
success: true,
settings: data,
wholesaleEnabled: data?.wholesale_enabled,
};
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
const data = await response.json();
return {
success: true,
settings: data,
wholesaleEnabled: data?.wholesale_enabled,
};
} catch {
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
}
}
export async function saveBrandSettings(params: {
@@ -331,8 +349,8 @@ export async function saveBrandSettings(params: {
return { success: false, error: "Not authorized for this brand" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
+8 -8
View File
@@ -64,8 +64,8 @@ export async function createOrder(
brandId?: string,
shippingAddress?: ShippingAddress
): Promise<CheckoutResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Calculate tax if brand collects tax ─────────────────────────────────
let taxAmount = 0;
@@ -150,8 +150,8 @@ export async function createOrder(
// ── Cart Persistence ──────────────────────────────────────────────────────────
export async function getServerCart(userId: string): Promise<CartItem[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
try {
const response = await fetch(
@@ -177,8 +177,8 @@ export async function mergeLocalCart(
): Promise<{ merged: CartItem[] }> {
if (!localCart || localCart.length === 0) return { merged: [] };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Fetch server cart
let serverCart: CartItem[] = [];
@@ -243,8 +243,8 @@ export async function mergeLocalCart(
}
export async function clearServerCart(userId: string): Promise<void> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
try {
await fetch(
+8 -8
View File
@@ -64,8 +64,8 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
}
const effectiveBrandId = activeBrandId;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`,
@@ -102,8 +102,8 @@ export async function upsertCampaign(params: {
return { success: false, error: "Not authorized to operate on this brand's campaigns" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_communication_campaign`,
@@ -144,8 +144,8 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
return { success: false, error: "Not authorized to delete this brand's campaigns" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_communication_campaign`,
@@ -164,8 +164,8 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
const adminUser = await getAdminUser();
if (!adminUser) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_campaign_by_id`,
+12 -12
View File
@@ -109,8 +109,8 @@ export async function getContacts(params: {
return { success: false, error: "Not authorized for this brand" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${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" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${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" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${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" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${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" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${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" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const allContacts: Contact[] = [];
let offset = 0;
+66 -30
View File
@@ -2,7 +2,9 @@
import { getAdminUser } from "@/lib/admin-permissions";
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 =
| { success: true; fileId: string; fileUrl: string; recordCount: number }
@@ -15,41 +17,57 @@ export async function uploadContactsToBucket(
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// Validate file type
if (!file.name.endsWith(".csv")) {
return { success: false, error: "Only CSV files are supported" };
}
// For very large files (farmers with tons of data), check size
const maxSize = 50 * 1024 * 1024; // 50MB max
if (file.size > maxSize) {
return { success: false, error: "File too large. Max 50MB for large imports." };
}
const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
const key = storageKeys.contactsImport(brandId, safeName);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Generate unique path
const timestamp = Date.now();
const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
const path = `imports/${brandId}/${timestamp}-${safeName}`;
// Upload to bucket
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
let url: string;
try {
const res = await uploadFile({
bucket: BUCKETS.CONTACTS_IMPORTS,
key,
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/${CONTACTS_BUCKET_ID}/${path}`,
{
method: "PUT",
headers: {
...svcHeaders(supabaseKey),
"Authorization": `Bearer ${supabaseKey}`,
"Content-Type": "text/csv",
"x-upsert": "false",
},
body: buffer,
contentType: "text/csv",
});
url = res.url;
} catch (e) {
return { success: false, error: `Upload failed: ${(e as Error).message}` };
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
}
const fileId = key.split("/").slice(0, 2).join("/"); // brandId/timestamp
const fileUrl = `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${path}`;
const fileId = `${brandId}/${timestamp}`;
// Get rough row count from file size (approx 200 bytes per row)
const estimatedRows = Math.floor(file.size / 200);
return {
success: true,
fileId,
fileUrl: url,
fileUrl,
recordCount: estimatedRows,
};
}
@@ -66,9 +84,10 @@ export async function processBucketImport(
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Call RPC to process the file from bucket
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`,
{
@@ -103,20 +122,37 @@ export async function listImportHistory(
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
try {
const files = await listFiles(BUCKETS.CONTACTS_IMPORTS, `${brandId}/`);
return {
success: true,
imports: files.slice(0, limit).map((f) => ({
filename: f.key.split("/").pop() ?? "",
size: f.size,
createdAt: f.lastModified.toISOString(),
url: publicUrl(BUCKETS.CONTACTS_IMPORTS, f.key),
})),
};
} catch (e) {
return { success: false, error: (e as Error).message };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// List files in the imports folder for this brand
const response = await fetch(
`${supabaseUrl}/storage/v1/object/list/${CONTACTS_BUCKET_ID}`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
prefix: `imports/${brandId}/`,
limit: limit,
sortBy: { column: "created_at", order: "desc" },
}),
}
);
if (!response.ok) {
return { success: false, error: "Failed to list imports" };
}
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 = {
+6 -6
View File
@@ -33,8 +33,8 @@ export async function getCommunicationSegments(
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_segments`,
@@ -64,8 +64,8 @@ export async function upsertSegment(params: {
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`,
@@ -99,8 +99,8 @@ export async function deleteSegment(
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_communication_segment`,
+6 -6
View File
@@ -22,8 +22,8 @@ export async function previewCampaignAudience(
return null;
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`,
@@ -87,8 +87,8 @@ export async function getMessageLogs(params: {
return { success: false, error: "Not authorized to view these logs" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_message_logs`,
@@ -133,8 +133,8 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis
return { success: false, error: "Not authorized to send this brand's campaigns" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/send_campaign`,
+4 -4
View File
@@ -24,8 +24,8 @@ export type UpsertSettingsResult = {
};
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${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" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_communication_settings`,
+2 -2
View File
@@ -22,8 +22,8 @@ export async function sendStopBlast(params: {
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/send_stop_blast`,
+6 -6
View File
@@ -45,8 +45,8 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
return { success: true, templates: [] };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_templates`,
@@ -75,8 +75,8 @@ export async function upsertTemplate(params: {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_communication_template`,
@@ -109,8 +109,8 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
const adminUser = await getAdminUser();
if (!adminUser) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_templates`,
+2 -2
View File
@@ -4,8 +4,8 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Types ────────────────────────────────────────────────────────────────────
@@ -3,8 +3,8 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -3,8 +3,8 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// ── Types ─────────────────────────────────────────────────────────────────────
+4 -4
View File
@@ -53,8 +53,8 @@ export async function getHarvestReachCampaigns(
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`,
@@ -82,8 +82,8 @@ export async function getCampaignAnalytics(
if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_campaign_analytics`,
+2 -2
View File
@@ -17,8 +17,8 @@ export async function getProductsForSegmentPicker(
if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_products_for_segment_picker`,
+8 -8
View File
@@ -75,8 +75,8 @@ export async function getHarvestReachSegments(
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_communication_segments`,
@@ -110,8 +110,8 @@ export async function upsertHarvestReachSegment(params: {
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`,
@@ -149,8 +149,8 @@ export async function deleteHarvestReachSegment(
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_communication_segment`,
@@ -180,8 +180,8 @@ export async function previewSegmentWithCustomers(
return null;
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`,
+2 -2
View File
@@ -23,8 +23,8 @@ export async function getStopsForSegmentPicker(
if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_stops_for_segment_picker`,
+2 -2
View File
@@ -25,8 +25,8 @@ export async function importOrdersBatch(
return { success: false, error: "Not authorized for this brand" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const results = { imported: 0, errors: [] as { row: number; error: string }[] };
+2 -2
View File
@@ -26,8 +26,8 @@ export async function importProductsBatch(
return { success: false, error: "Not authorized for this brand" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`,
+10 -10
View File
@@ -33,8 +33,8 @@ const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1";
// ── Get AI provider settings ─────────────────────────────────────────────────────
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_ai_provider_settings`,
@@ -75,8 +75,8 @@ export async function setAIProviderSettings(
const current = await getAIProviderSettings(brandId);
const merged = { ...current, ...settings };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const payload = {
provider: merged.provider,
@@ -206,8 +206,8 @@ export async function getAIClient(brandId: string): Promise<{
// ── Custom integrations ─────────────────────────────────────────────────────────
export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_custom_integrations`,
@@ -234,8 +234,8 @@ export async function upsertCustomIntegration(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_custom_integration`,
@@ -263,8 +263,8 @@ export async function deleteCustomIntegration(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_custom_integration`,
+8 -8
View File
@@ -24,8 +24,8 @@ export type SaveCredentialsResult =
// ── Resend Credentials ─────────────────────────────────────────────────────────
export async function getResendCredentials(brandId: string): Promise<ResendCredentials> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_resend_credentials`,
@@ -66,8 +66,8 @@ export async function saveResendCredentials(
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get current credentials to merge
const current = await getResendCredentials(brandId);
@@ -99,8 +99,8 @@ export async function saveResendCredentials(
// ── Twilio Credentials ─────────────────────────────────────────────────────────
export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_twilio_credentials`,
@@ -141,8 +141,8 @@ export async function saveTwilioCredentials(
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get current credentials to merge
const current = await getTwilioCredentials(brandId);
+41 -16
View File
@@ -1,7 +1,7 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
export type LoginWithPasswordResult =
| { success: true; redirect: true }
@@ -11,21 +11,46 @@ export async function loginWithPassword(
email: string,
password: string
): Promise<LoginWithPasswordResult> {
try {
const hdrs = await headers();
const result = await auth.api.signInEmail({
body: { email, password },
headers: hdrs,
asResponse: false,
});
const cookieStore = await cookies();
if (!result?.user) {
return { success: false, error: "Invalid credentials" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
return { success: true, redirect: true };
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Invalid credentials";
return { success: false, error: message };
if (!supabaseUrl || !supabaseAnonKey) {
return { success: false, error: "Server misconfiguration." };
}
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 };
}
+64 -4
View File
@@ -2,6 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { mockOrders, mockStops } from "@/lib/mock-data";
type AdminOrder = {
id: string;
@@ -105,12 +106,51 @@ 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> {
// Return mock data in mock mode
if (useMockData) {
return {
success: true,
orders: mockAdminOrders,
stops: mockAdminStops,
error: null,
};
}
const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_admin_orders`,
@@ -153,11 +193,31 @@ export async function getAdminPickedUpOrders(): Promise<AdminOrder[]> {
}
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 brandId = adminUser?.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_admin_order_detail`,
+2 -2
View File
@@ -58,8 +58,8 @@ export async function createAdminOrder(
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!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Build items for RPC (match checkout shape)
const rpcItems = input.items.map((i) => ({
+2 -2
View File
@@ -22,8 +22,8 @@ export async function createRefund(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
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/refunds`, {
method: "POST",
+6 -6
View File
@@ -36,8 +36,8 @@ export async function updateOrder(
return { success: false, error: "Not authorized for this brand" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const patchData: Record<string, unknown> = {};
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.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const patchData: Record<string, unknown> = {};
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.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
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/order_items?id=eq.${itemId}`, {
method: "DELETE",
+4 -4
View File
@@ -26,8 +26,8 @@ export type GetPaymentSettingsResult =
| { success: false; error: string };
export async function getPaymentSettings(brandId: string): Promise<GetPaymentSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const response = await fetch(
`${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" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`,
+10 -10
View File
@@ -28,9 +28,9 @@ export async function markPickupComplete(
// brand_admin: verify the order belongs to their brand
if (adminUser.role === "brand_admin" && adminUser.brand_id) {
const brandRes = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/rest/v1/orders?id=eq.${orderId}&select=stop_id,brand_id`,
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=stop_id,brand_id`,
{
headers: svcHeaders(process.env.NEXT_PUBLIC_API_ANON_KEY!),
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
}
);
@@ -52,9 +52,9 @@ export async function markPickupComplete(
if (!order.brand_id && order.stop_id) {
const stopRes = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/rest/v1/stops?id=eq.${order.stop_id}&select=brand_id`,
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?id=eq.${order.stop_id}&select=brand_id`,
{
headers: svcHeaders(process.env.NEXT_PUBLIC_API_ANON_KEY!),
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
}
);
@@ -69,11 +69,11 @@ export async function markPickupComplete(
// PATCH the order
const patchRes = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/rest/v1/orders?id=eq.${orderId}`,
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}`,
{
method: "PATCH",
headers: {
...svcHeaders(process.env.NEXT_PUBLIC_API_ANON_KEY!),
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
"Content-Type": "application/json",
Prefer: "return=representation",
},
@@ -111,9 +111,9 @@ export async function markPickupComplete(
// Emit pickup_completed event
// Need brand_id — get it from the order we just patched
const orderRes = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/rest/v1/orders?id=eq.${orderId}&select=brand_id`,
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=brand_id`,
{
headers: svcHeaders(process.env.NEXT_PUBLIC_API_ANON_KEY!),
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
}
);
if (orderRes.ok) {
@@ -121,11 +121,11 @@ export async function markPickupComplete(
const orderBrandId = Array.isArray(orderData) && orderData[0]?.brand_id;
if (orderBrandId) {
await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/record_pickup_completed_event`,
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/record_pickup_completed_event`,
{
method: "POST",
headers: {
...svcHeaders(process.env.NEXT_PUBLIC_API_ANON_KEY!),
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
"Content-Type": "application/json",
},
body: JSON.stringify({
+2 -2
View File
@@ -3,8 +3,8 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Types ────────────────────────────────────────────────────────────────────
+4 -4
View File
@@ -23,8 +23,8 @@ export async function deleteProduct(
}
const effectiveBrandId = activeBrandId;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_product`,
@@ -68,8 +68,8 @@ async function logAuditEvent(event: {
new_data: Record<string, unknown>;
brand_id: string | null;
}) {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
await fetch(
`${supabaseUrl}/rest/v1/rpc.log_audit_event`,
+24 -2
View File
@@ -1,8 +1,11 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getMockTableData } from "@/lib/mock-data";
import { svcHeaders } from "@/lib/svc-headers";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type CreateProductResult =
| { success: true; id: string }
| { success: false; error: string };
@@ -28,8 +31,27 @@ export async function createProduct(
return { success: false, error: "Not authorized for this brand" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
if (useMockData) {
const mockProducts = getMockTableData("products") as any[];
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`, {
method: "POST",
+9 -2
View File
@@ -1,8 +1,11 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getMockTableData } from "@/lib/mock-data";
import { svcHeaders } from "@/lib/svc-headers";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type UpdateProductResult =
| { success: true }
| { success: false; error: string };
@@ -29,8 +32,12 @@ export async function updateProduct(
return { success: false, error: "Not authorized for this brand" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
if (useMockData) {
return { success: true };
}
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}`, {
method: "PATCH",
+31 -23
View File
@@ -2,7 +2,9 @@
import { getAdminUser } from "@/lib/admin-permissions";
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 =
| { success: true; imageUrl: string }
@@ -23,41 +25,47 @@ export async function uploadProductImage(
return { success: false, error: "File too large. Max 5MB." };
}
const rawExt = file.type.split("/")[1];
const ext = rawExt === "jpeg" ? "jpg" : rawExt;
const targetProductId = productId === "__NEW__" ? "new" : productId;
const key = storageKeys.productImage(targetProductId, ext);
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
const path = `products/${productId}/${crypto.randomUUID()}.${ext}`;
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
let url: string;
try {
const res = await uploadFile({
bucket: BUCKETS.PRODUCT_IMAGES,
key,
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
{
method: "PUT",
headers: {
"apikey": supabaseKey,
"Authorization": `Bearer ${supabaseKey}`,
"Content-Type": `image/${ext}`,
"x-upsert": "true"
},
body: buffer,
contentType: file.type,
});
url = res.url;
} catch (e) {
return { success: false, error: `Upload failed: ${(e as Error).message}` };
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${PRODUCT_IMAGES_BUCKET_ID}/${path}`;
// If productId is "__NEW__", we just upload and return the URL (caller handles saving it)
if (productId === "__NEW__") {
return { success: true, imageUrl: url };
return { success: true, imageUrl: publicUrl };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Update product record with new image URL
const patchRes = await fetch(
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
{
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ image_url: url }),
body: JSON.stringify({ image_url: publicUrl }),
}
);
@@ -65,7 +73,7 @@ export async function uploadProductImage(
return { success: false, error: "Upload succeeded but failed to save image URL" };
}
return { success: true, imageUrl: url };
return { success: true, imageUrl: publicUrl };
}
export async function deleteProductImage(
@@ -74,8 +82,8 @@ export async function deleteProductImage(
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const patchRes = await fetch(
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
+2 -2
View File
@@ -3,8 +3,8 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export type DateRange = { start: string; end: string };
+1 -1
View File
@@ -4,7 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
const SUPABASE_URL = process.env.NEXT_PUBLIC_API_URL!;
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_PAT = process.env.SUPABASE_PAT!;
async function adminFetch(endpoint: string, options?: RequestInit) {
+2 -2
View File
@@ -23,8 +23,8 @@ export async function toggleBrandFeature(
return { success: false, error: "Not authorized for this brand" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
+4 -4
View File
@@ -17,8 +17,8 @@ export async function updateShippingStatus(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_shipping_order`,
@@ -71,8 +71,8 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_shipping_orders`,
+6 -6
View File
@@ -59,8 +59,8 @@ async function getFedExTokenForCreate(
brandId: string | null,
adminUserId: string | null
): Promise<{ accessToken: string } | { error: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Try to get from shipping_settings
const settingsRes = await fetch(
@@ -89,8 +89,8 @@ async function getFedExTokenForCreate(
}
async function isShipmentPerishable(orderId: string): Promise<boolean> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`,
@@ -129,8 +129,8 @@ export async function createFedExShipment(
return { success: false, error: "Not authorized to create shipments" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Fetch order
const orderRes = await fetch(
+4 -4
View File
@@ -96,8 +96,8 @@ async function isOrderPerishable(
orderId: string,
brandId: string | null
): Promise<boolean> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`,
@@ -152,8 +152,8 @@ export async function getFedExRates(
return { success: false, error: "Not authorized to view shipping rates" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Fetch order + brand + address
const orderRes = await fetch(
+4 -4
View File
@@ -77,8 +77,8 @@ async function getFedExToken(apiKey: string, apiSecret: string, useProduction: b
// ── Get Settings ─────────────────────────────────────────────────────────────
export async function getShippingSettings(brandId: string): Promise<GetShippingSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${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" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Get existing settings to get ID for update
const existing = await fetch(
+4 -4
View File
@@ -110,8 +110,8 @@ export async function syncInventoryToSquare(
const errors: string[] = [];
const synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Build product name → quantity map
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 synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try {
// Fetch Square inventory counts for the location
+2 -2
View File
@@ -85,8 +85,8 @@ export async function syncOrdersFromSquare(brandId: string): Promise<SyncResult>
const errors: string[] = [];
let synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Determine sync start time — last sync or 30 days ago
const since = settings.square_last_sync_at
+4 -4
View File
@@ -136,8 +136,8 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
try {
// Fetch wholesale products via RPC (avoids rc_product_id bug in direct query)
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const rpcRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
@@ -247,8 +247,8 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
const errors: string[] = [];
let synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
let cursor: string | undefined;
do {
+2 -2
View File
@@ -70,8 +70,8 @@ export async function getSyncLog(brandId: string): Promise<{
return { success: false, logs: [] };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/square_sync_log?brand_id=eq.${brandId}&order=created_at.desc&limit=10`,
+55 -35
View File
@@ -35,11 +35,11 @@ export async function createStopsBatch(
return { success: false, created: 0, error: "No brand selected" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
// 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 supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const rows = stops.map((s) => ({
city: s.city,
@@ -79,8 +79,8 @@ export async function publishStop(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, {
method: "PATCH",
@@ -110,29 +110,38 @@ export type StopForSitemap = {
};
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Returning
// an empty list lets the sitemap render with only the static URLs.
if (!supabaseUrl || !supabaseKey) return [];
// Get all active stops with their brand slug
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
}
);
// Get all active stops with their brand slug. Wrapped in try/catch so a
// build-time outage (ECONNREFUSED) doesn't crash the prerender — the
// sitemap just renders without stop URLs.
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
}
);
if (!response.ok) return [];
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? stops : [];
const stops = await response.json();
return Array.isArray(stops) ? stops : [];
} catch {
return [];
}
}
/**
* 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
* `supabase.from("stops")` query so supabase-js no longer ships to
* the browser for those routes.
*
* Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')`
@@ -155,24 +164,35 @@ export async function getPublicStopsForBrand(
): Promise<PublicStop[]> {
if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Returning
// an empty list lets the page render with zero stops; at runtime the
// fetch path is unchanged.
if (!supabaseUrl || !supabaseKey) return [];
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`],
},
}
);
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just renders with no stops
// and revalidates from a real request once the cache is warm.
try {
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 [];
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
} catch {
return [];
}
}
+13 -5
View File
@@ -3,6 +3,9 @@
import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions";
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 =
| { success: true; id: string }
@@ -30,13 +33,18 @@ export async function createStop(
return { success: false, error: "Not authorized for this brand" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
if (useMockData) {
const mockStops = getMockTableData("stops") as Array<{ id: string }>;
return { success: true, id: `mock-stop-${Date.now()}` };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
// Preferred path: anon key + SECURITY DEFINER RPC (bypasses RLS, works even if
// service role key is absent at runtime). See:
// api/migrations/202_fix_admin_create_stop.sql
// supabase/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!;
// supabase/ADMIN_CREATE_STOP_FIX.sql (exact prompt SQL for SQL editor)
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stop`, {
method: "POST",
@@ -106,7 +114,7 @@ export async function createStop(
" 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" +
" 3. Or apply supabase/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.",
};
}
+8 -2
View File
@@ -4,6 +4,8 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { logAuditEvent } from "@/actions/audit";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type UpdateStopResult =
| { success: true }
| { success: false; error: string };
@@ -31,8 +33,12 @@ export async function updateStop(
return { success: false, error: "Not authorized for this brand" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
if (useMockData) {
return { success: true };
}
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}`;
+6 -6
View File
@@ -19,8 +19,8 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
const adminUser = await getAdminUser();
if (!adminUser) return { is_connected: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get brand's payment settings
const res = await fetch(
@@ -183,8 +183,8 @@ export async function saveStripeConnectAccount(brandId: string, accountId: strin
success: boolean;
error?: string;
}> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Save to payment_settings via RPC
const res = await fetch(
@@ -220,8 +220,8 @@ export async function disconnectStripeConnect(brandId: string): Promise<{
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
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/rpc/disconnect_stripe_connect`,
+2 -2
View File
@@ -102,8 +102,8 @@ export async function calculateOrderTax(params: {
}
async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings`,
+2 -2
View File
@@ -2,8 +2,8 @@
import { cookies } from "next/headers";
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
function rpcBody(body: Record<string, unknown>) {
return JSON.stringify(body);
+111 -2
View File
@@ -2,9 +2,13 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data";
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_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>) {
return JSON.stringify(body);
@@ -58,6 +62,20 @@ export type TimeSummary = {
// ── Workers ───────────────────────────────────────────────────────────────────
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(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_workers`,
{
@@ -164,6 +182,16 @@ export async function deleteTimeWorker(workerId: string): Promise<{ success: boo
// ── Tasks ────────────────────────────────────────────────────────────────────
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(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_tasks`,
{
@@ -254,6 +282,30 @@ export async function getWorkerTimeLogs(
offset?: number;
} = {}
): 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(
`${supabaseUrl}/rest/v1/rpc/get_worker_time_logs`,
{
@@ -328,6 +380,39 @@ export async function getTimeTrackingSummary(
start: string,
end: string
): 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(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_summary`,
{
@@ -362,6 +447,26 @@ export type TimeTrackingSettings = {
};
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(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_settings`,
{
@@ -447,6 +552,10 @@ export async function getTimeTrackingNotificationLog(
brandId: string,
limit = 100
): Promise<NotificationLogEntry[]> {
if (useMockData) {
// Return empty log in mock mode
return [];
}
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_notification_log`,
{
+2 -2
View File
@@ -3,8 +3,8 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
export type OvertimeCheckResult = {
sent: boolean;
+32 -32
View File
@@ -51,8 +51,8 @@ export async function createWaterHeadgate(brandId: string, name: string, unit: s
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 supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // prefer service for admin muts
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_water_headgate`,
@@ -82,8 +82,8 @@ export async function updateWaterHeadgate(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_water_headgate`,
@@ -112,8 +112,8 @@ export async function updateWaterHeadgate(
// ── Irrigator Admin ─────────────────────────────────────────
export async function getWaterIrrigators(brandId: string): Promise<Irrigator[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_users`,
@@ -143,8 +143,8 @@ export async function createWaterUser(
role: "irrigator" | "water_admin",
lang: string = "en"
): Promise<{ success: boolean; user?: Irrigator; pin?: string; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_water_user`,
@@ -173,8 +173,8 @@ export async function updateWaterIrrigator(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_water_user`,
@@ -206,8 +206,8 @@ export async function resetWaterIrrigatorPin(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/reset_water_user_pin`,
@@ -233,8 +233,8 @@ export async function deleteWaterUser(userId: string): Promise<{ success: boolea
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_water_user`,
@@ -260,8 +260,8 @@ export async function deleteWaterHeadgate(headgateId: string): Promise<{ success
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_water_headgate`,
@@ -309,8 +309,8 @@ export async function deleteWaterHeadgate(headgateId: string): Promise<{ success
// ── Entries ────────────────────────────────────────────────
export async function getWaterEntries(brandId: string, limit = 50): Promise<WaterEntry[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_entries`,
@@ -327,8 +327,8 @@ export async function getWaterEntries(brandId: string, limit = 50): Promise<Wate
}
export async function getWaterHeadgatesAdmin(brandId: string): Promise<Headgate[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_headgates_admin`,
@@ -349,8 +349,8 @@ export async function regenerateHeadgateToken(headgateId: string): Promise<{ suc
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/regenerate_headgate_token`,
@@ -380,8 +380,8 @@ export async function updateWaterEntry(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_water_entry`,
@@ -412,8 +412,8 @@ export async function deleteWaterEntry(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_water_entry`,
@@ -435,8 +435,8 @@ export async function deleteWaterEntry(
}
export async function getWaterEntryById(entryId: string): Promise<WaterEntry | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_entry_by_id`,
@@ -482,8 +482,8 @@ export type WaterDisplaySummary = {
};
export async function getWaterDisplaySummary(brandId: string): Promise<WaterDisplaySummary | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_display_summary`,
@@ -512,8 +512,8 @@ export type AlertLogEntry = {
};
export async function getWaterAlertLog(brandId: string, limit = 50): Promise<AlertLogEntry[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_alert_log`,
+8 -8
View File
@@ -35,8 +35,8 @@ type HeadgatesResult = {
};
export async function getWaterHeadgates(brandId: string, activeOnly = false): Promise<Headgate[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${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> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/verify_water_pin`,
@@ -134,8 +134,8 @@ export async function submitWaterEntry(
return { success: false, error: "Not logged in" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${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;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Use get_water_admin_session RPC (SECURITY DEFINER) to avoid direct water_sessions + water_users JOIN
const response = await fetch(
+6 -6
View File
@@ -14,8 +14,8 @@ export type WaterAdminSettings = {
};
export async function getWaterAdminSettings(brandId: string): Promise<WaterAdminSettings | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${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.can_manage_water_log) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
let pinHash: string | null = null;
if (settings.pin) {
@@ -88,8 +88,8 @@ export async function verifyWaterAdminPin(
brandId: string,
pin: string
): Promise<{ success: boolean; session_id?: string; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const settingsRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_admin_settings`,
+110 -41
View File
@@ -1,7 +1,8 @@
"use server";
import { cookies, headers } from "next/headers";
import { auth } from "@/lib/auth";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
export type WholesaleLoginResult =
| { success: true; token: string; userId: string; customerId: string }
@@ -11,53 +12,121 @@ export async function wholesaleLoginAction(formData: FormData): Promise<Wholesal
const email = formData.get("email") as string;
const password = formData.get("password") as string;
try {
const hdrs = await headers();
const result = await auth.api.signInEmail({
body: { email, password },
headers: hdrs,
asResponse: false,
});
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
if (!result?.user) {
return { success: false, error: "Invalid credentials" };
}
const cookieStore = await cookies();
const request = new NextRequest("http://localhost/wholesale/login", {
headers: new Headers(),
});
const response = NextResponse.next({ request });
const cookieStore = await cookies();
// Better Auth sets its own session cookie (rc_session_token).
// Mark wholesale session for portal routing.
cookieStore.set("wholesale_session", JSON.stringify({
user_id: result.user.id,
}), {
path: "/",
maxAge: 3600 * 24 * 7,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: false,
});
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);
});
},
},
});
return {
success: true,
token: "better-auth-session", // session lives in cookie
userId: result.user.id,
customerId: "pending", // resolved by portal page on load
};
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Invalid credentials";
return { success: false, error: message };
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
// For now, set the cookie with user_id and a placeholder; portal will resolve proper customer
response.cookies.set("wholesale_session", JSON.stringify({
user_id: data.user.id,
access_token: token,
}), {
path: "/",
maxAge: 3600 * 24 * 7,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: false,
});
// Also set the standard auth token for RPC calls
response.cookies.set("sb-wnzkhezyhnfzhkhiflrp-auth-token", token, {
path: "/",
maxAge: 3600 * 24 * 7,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: false,
});
return {
success: true,
token,
userId: data.user.id,
customerId: "pending", // resolved by portal page on load
};
}
export async function wholesaleLogoutAction() {
try {
const hdrs = await headers();
await auth.api.signOut({ headers: hdrs });
} catch {
// best-effort
}
const cookieStore = await cookies();
cookieStore.delete("wholesale_session");
const request = new NextRequest("http://localhost/wholesale/portal", {
headers: new Headers(),
});
const response = NextResponse.next({ request });
response.cookies.delete("wholesale_session");
const { createServerClient } = await import("@supabase/ssr");
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
},
},
});
await supabase.auth.signOut();
return { success: true };
}
+24 -24
View File
@@ -11,8 +11,8 @@ export async function registerWholesaleCustomer(params: {
email: string;
phone?: string;
}): Promise<{ success: boolean; error?: string; requiresApproval?: boolean }> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/register_wholesale_customer`,
@@ -55,8 +55,8 @@ export async function getPendingWholesaleRegistrations(brandId: string) {
if (!adminUser) return [];
if (!adminUser.can_manage_orders) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_pending_wholesale_registrations`,
@@ -88,8 +88,8 @@ export async function approveWholesaleRegistration(
return { success: false, error: "Not authorized to operate on this brand" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/approve_wholesale_registration`,
@@ -131,8 +131,8 @@ export async function getWholesaleCustomerByUser(
role: string;
brand_id: string;
} | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`,
@@ -165,8 +165,8 @@ export async function getWholesaleCustomer(
role: string;
brand_id: string;
} | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
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`,
@@ -182,8 +182,8 @@ export async function getWholesaleCustomer(
export async function getWholesaleProducts(brandId: string) {
// Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
@@ -220,8 +220,8 @@ export async function submitWholesaleOrder(params: {
orderTotal?: number;
idempotent?: boolean;
}> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Generate UUID at the start of checkout — before RPC call for true idempotency
const checkoutSessionId = params.checkoutSessionId ?? crypto.randomUUID();
@@ -287,8 +287,8 @@ export async function submitWholesaleOrder(params: {
export async function enqueueWholesaleWebhookForOrderCreated(orderId: string, brandId: string, customerId: string, invoiceNumber: string | null, subtotal: number) {
try {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY ?? process.env.SUPABASE_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!;
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
@@ -344,8 +344,8 @@ export type WholesalePricingOverride = {
};
export async function getWholesaleCustomerPricing(customerId: string): Promise<WholesalePricingOverride[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_pricing`,
@@ -368,8 +368,8 @@ export async function upsertWholesaleCustomerPricing(params: {
productId: string;
customUnitPrice: number;
}): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer_pricing`,
@@ -395,8 +395,8 @@ export async function deleteWholesaleCustomerPricing(params: {
customerId: string;
productId: string;
}): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer_pricing`,
@@ -418,8 +418,8 @@ export async function deleteWholesaleCustomerPricing(params: {
}
export async function getWholesaleCustomerOrders(customerId: string): Promise<WholesaleCustomerOrder[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_orders`,
+52 -52
View File
@@ -190,8 +190,8 @@ export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrd
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
@@ -214,8 +214,8 @@ export async function getWholesalePickupOrders(brandId?: string): Promise<Wholes
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_pickup_orders`,
@@ -262,8 +262,8 @@ export async function markWholesaleOrderFulfilled(orderId: string, brandId?: str
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/mark_wholesale_order_fulfilled`,
@@ -285,8 +285,8 @@ export async function markWholesaleOrderFulfilled(orderId: string, brandId?: str
}
async function enqueueWholesaleWebhookForOrderFulfilled(orderId: string, brandId?: string) {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY ?? process.env.SUPABASE_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!;
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
@@ -311,8 +311,8 @@ export async function updateWholesaleOrderStatus(
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_wholesale_order_status`,
@@ -338,8 +338,8 @@ export async function deleteWholesaleOrder(orderId: string, brandId?: string): P
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_order`,
@@ -365,8 +365,8 @@ export async function deleteWholesaleCustomer(customerId: string, brandId?: stri
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer`,
@@ -394,8 +394,8 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_product`,
@@ -422,8 +422,8 @@ export async function getWholesaleCustomers(brandId?: string): Promise<Wholesale
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customers`,
@@ -464,8 +464,8 @@ export async function saveWholesaleCustomer(params: {
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer`,
@@ -506,8 +506,8 @@ export async function getWholesaleProducts(brandId?: string): Promise<WholesaleP
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
@@ -551,8 +551,8 @@ export async function saveWholesaleProduct(params: {
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_product`,
@@ -599,8 +599,8 @@ export async function getWholesaleSettings(brandId?: string): Promise<WholesaleS
return null;
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`,
@@ -641,8 +641,8 @@ export async function saveWholesaleSettings(params: {
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_settings`,
@@ -689,8 +689,8 @@ export async function recordWholesaleDeposit(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/record_wholesale_deposit`,
@@ -722,8 +722,8 @@ export async function recordWholesaleDeposit(
}
async function enqueueWholesaleWebhookForDepositRecorded(orderId: string, amount: number, brandId?: string) {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY ?? process.env.SUPABASE_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!;
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
@@ -745,8 +745,8 @@ export async function bulkFulfillWholesaleOrders(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/bulk_fulfill_wholesale_orders`,
@@ -780,8 +780,8 @@ export async function bulkRecordWholesaleDeposit(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/bulk_record_wholesale_deposit`,
@@ -830,8 +830,8 @@ export type WholesaleNotification = {
export async function getWholesaleNotificationStats(
brandId: string
): Promise<{ pending: number; sent: number; failed: number; total: number }> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_notification_stats`,
@@ -857,8 +857,8 @@ export async function getWholesalePendingNotifications(
if (!adminUser) return [];
if (!adminUser.can_manage_orders) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_pending_notifications`,
@@ -884,8 +884,8 @@ export async function markWholesaleNotificationSent(
if (!adminUser) return { success: false };
if (!adminUser.can_manage_orders) return { success: false };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/mark_wholesale_notification_sent`,
@@ -913,8 +913,8 @@ export async function enqueueWholesaleNotification(params: {
bodyHtml?: string;
bodyText?: string;
}): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`,
@@ -955,8 +955,8 @@ export type WebhookSettings = {
};
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/wholesale_webhook_settings?brand_id=eq.${brandId}&select=*`,
@@ -980,8 +980,8 @@ export async function saveWebhookSettings(params: {
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const body: Record<string, unknown> = {
brand_id: params.brandId,
@@ -1013,8 +1013,8 @@ export async function enqueueWholesaleWebhook(
payload: Record<string, unknown> | null = null,
brandId?: string
): Promise<{ success: boolean; logId?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY ?? process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`,
@@ -1047,8 +1047,8 @@ export async function getRecentWebhookActivity(brandId: string, limit = 10): Pro
created_at: string;
response: string | null;
}>> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY ?? process.env.NEXT_PUBLIC_API_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/wholesale_sync_log?brand_id=eq.${brandId}&order=created_at.desc&limit=${limit}`,
+2 -2
View File
@@ -11,8 +11,8 @@ export default async function DebugAuthPage() {
let adminUsersResult: string | null = null;
if (rcAuthUid) {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const serviceKey = process.env.POSTGREST_SERVICE_KEY;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (supabaseUrl && serviceKey) {
try {
const res = await fetch(
+5
View File
@@ -8,6 +8,11 @@ import "@/styles/admin-design-system.css";
import { ToastProvider } from "@/components/admin/Toast";
import { ToastContainer } from "@/components/admin/ToastContainer";
// Admin layout calls getAdminUser() which reads cookies(). Without this,
// Next.js tries to prerender the entire /admin/* tree statically and the
// first page that hits cookies() aborts the build with DYNAMIC_SERVER_USAGE.
export const dynamic = "force-dynamic";
// Toast provider wrapper component
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
return (
+11 -12
View File
@@ -2,10 +2,9 @@
import { useState } from "react";
import Link from "next/link";
import { authClient } from "@/lib/auth-client";
import { supabase } from "@/lib/supabase";
import { AdminUserRow } from "@/actions/admin/users";
import { logUserActivity } from "@/actions/admin/audit";
import { updateAdminProfileAction } from "@/actions/admin/profile";
type ProfilePageProps = {
currentUser: AdminUserRow;
@@ -27,13 +26,13 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
setSaving(true);
setError(null);
try {
const result = await updateAdminProfileAction(
currentUser.id,
displayName || null,
phoneNumber || null
);
if (!result.success) {
setError(result.error);
const { error: rpcError } = await supabase.rpc("update_admin_user", {
p_id: currentUser.id,
p_display_name: displayName || null,
p_phone_number: phoneNumber || null,
});
if (rpcError) {
setError(rpcError.message);
return;
}
await logUserActivity({
@@ -52,11 +51,11 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
setChangingEmail(true);
setEmailError(null);
try {
const { error: updateError } = await authClient.changeEmail({
newEmail: newEmail,
const { error: updateError } = await supabase.auth.updateUser({
email: newEmail,
});
if (updateError) {
setEmailError(updateError.message ?? "Failed to change email");
setEmailError(updateError.message);
return;
}
await logUserActivity({
+2 -2
View File
@@ -5,7 +5,7 @@ import { getAdminOrders } from "@/actions/orders";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation";
import { api } from "@/lib/api";
import { supabase } from "@/lib/supabase";
export const dynamic = "force-dynamic";
@@ -40,7 +40,7 @@ export default async function AdminOrdersPage() {
// Fetch active products for this brand (for admin "New Order" item picker)
let brandProducts: Array<{ id: string; name: string; price: number; type?: string | null; active?: boolean }> = [];
try {
let prodQuery = api
let prodQuery = supabase
.from("products")
.select("id, name, price, type, active")
.eq("active", true)
+2 -2
View File
@@ -1,5 +1,5 @@
import Link from "next/link";
import { api } from "@/lib/api";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { isFeatureEnabled } from "@/lib/feature-flags";
@@ -30,7 +30,7 @@ export default async function AdminPage() {
// "Active Products" stat in sync with the billing page.
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
const { data: firstBrand } = await api
const { data: firstBrand } = await supabase
.from("brands")
.select("id")
.limit(1)

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