Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b02154188 | |||
| 6e71596daf | |||
| 4da7aae5ce | |||
| ccfd89be75 | |||
| 42f28b32a4 | |||
| 720ffe5262 | |||
| 7cd0603cfb | |||
| f96dcd01f2 | |||
| 3f731f7739 | |||
| 5654ebaecd | |||
| 1cecbce392 | |||
| b63d0415ab | |||
| e2e56252ec | |||
| 48ce5665b9 | |||
| 2d55791458 | |||
| 6c5ca6829f | |||
| 1e9f9c0414 | |||
| 53d995fc99 | |||
| e499139c74 | |||
| 7489da3da0 | |||
| 32396af193 | |||
| f36419be69 | |||
| 5477b3419f | |||
| 2f3be5426f | |||
| 2d837bc786 | |||
| bb6dbe37a4 | |||
| 3f4f46da7e | |||
| ec1506dc82 | |||
| 2b3fd214d8 | |||
| 858ca0d64d | |||
| 8a91494009 | |||
| b240b7b56e | |||
| fd9d6424d5 | |||
| 0b748adfaa | |||
| 69767eb250 | |||
| 8bca9e86b1 | |||
| 82d81b2f69 | |||
| 7ecb1f2fc0 | |||
| 015b1cf7b5 | |||
| 1b12a0a95d | |||
| b2aa53f274 | |||
| 66c6f45efc | |||
| 6c6b5d3053 | |||
| 73cc7d1dce | |||
| 4763884caf | |||
| bdcaf0f1da | |||
| df6f4181df | |||
| 63842a9efc | |||
| bd623020d5 | |||
| 9b51f5ae29 | |||
| bc29c70e8b | |||
| 1feba25ced | |||
| f26d43ac96 | |||
| 49a9900d15 |
@@ -0,0 +1,89 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# 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.).
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# ── App ─────────────────────────────────────────────────────────────────────
|
||||||
|
NEXT_PUBLIC_BASE_URL=http://localhost:4000
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost:4000
|
||||||
|
|
||||||
|
# ── 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
|
||||||
|
|
||||||
|
# ── Auth.js (NextAuth v5) ───────────────────────────────────────────────────
|
||||||
|
# Generate with: npx auth secret
|
||||||
|
# Or: openssl rand -base64 32
|
||||||
|
AUTH_SECRET=replace-me-with-a-32-byte-base64-string
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Comma-separated list of email addresses allowed to sign in via Google
|
||||||
|
# OAuth. If unset (or empty), any Google account can sign in and gets a
|
||||||
|
# `platform_admin` row auto-created — fine for demo/dev. Set this in
|
||||||
|
# production to lock sign-in down to a known set of admins.
|
||||||
|
# Example: ADMIN_ALLOWED_EMAILS=tyler@example.com,sarah@example.com
|
||||||
|
ADMIN_ALLOWED_EMAILS=
|
||||||
|
|
||||||
|
# ── 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=https://api.minimax.io/v1
|
||||||
|
|
||||||
|
# ── Square (optional) ───────────────────────────────────────────────────────
|
||||||
|
SQUARE_APP_SECRET=
|
||||||
|
SQUARE_ENVIRONMENT=sandbox
|
||||||
|
|
||||||
|
# ── Cron / automation ───────────────────────────────────────────────────────
|
||||||
|
CRON_SECRET=replace-me-with-a-random-string
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
name: Deploy to route.crispygoat.com
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Start Docker stack
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||||
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||||
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
|
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||||
|
|
||||||
|
# PostgREST — needs the DB URI at start time (it reads env
|
||||||
|
# from the container, not from .env.production which is
|
||||||
|
# written later by the Deploy step).
|
||||||
|
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
|
||||||
|
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
|
||||||
|
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
|
||||||
|
run: |
|
||||||
|
APP_DIR=/home/tyler/route-commerce
|
||||||
|
mkdir -p $APP_DIR
|
||||||
|
|
||||||
|
# Seed config files into APP_DIR FIRST, before any docker compose
|
||||||
|
# command. The `docker compose down` below validates the compose
|
||||||
|
# file (including `env_file` paths) — if the old copy is still
|
||||||
|
# on the server with a broken `env_file`, the step fails before
|
||||||
|
# we get a chance to overwrite it.
|
||||||
|
# - docker-compose.yml: copied UNCONDITIONALLY so deploys pick
|
||||||
|
# up compose changes. The previous `[ -f ... ] ||` guard
|
||||||
|
# kept stale copies on the server.
|
||||||
|
# - .env.example: copied on first deploy only (it's a template;
|
||||||
|
# the real `.env` is built from it below).
|
||||||
|
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
|
||||||
|
cp -f deploy/docker-compose.yml $APP_DIR/docker-compose.yml
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
cd $APP_DIR
|
||||||
|
[ -f .env ] || cp .env.example .env
|
||||||
|
# Append production secrets to .env (overriding .env.example defaults)
|
||||||
|
{
|
||||||
|
echo "POSTGRES_USER=${POSTGRES_USER}"
|
||||||
|
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||||
|
echo "POSTGRES_DB=${POSTGRES_DB}"
|
||||||
|
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
|
||||||
|
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
|
||||||
|
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}"
|
||||||
|
echo "PGRST_DB_URI=${PGRST_DB_URI}"
|
||||||
|
echo "PGRST_DB_ANON_ROLE=${PGRST_DB_ANON_ROLE:-anon}"
|
||||||
|
echo "PGRST_SERVER_PORT=${PGRST_SERVER_PORT:-3000}"
|
||||||
|
echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT"
|
||||||
|
echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL"
|
||||||
|
} >> .env
|
||||||
|
# Bring the stack up fresh — --force-recreate ensures no stale
|
||||||
|
# network/container references from prior failed attempts.
|
||||||
|
# Only `postgrest` lives in docker; Postgres itself runs on the
|
||||||
|
# host (see the migrations step below, which uses
|
||||||
|
# `psql -h 127.0.0.1`).
|
||||||
|
docker compose up -d --force-recreate postgrest
|
||||||
|
# Wait for Postgres to accept connections on the host.
|
||||||
|
# The DB is on 127.0.0.1, not in a docker service.
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "SELECT 1" > /dev/null 2>&1; then
|
||||||
|
echo "Postgres is ready"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Apply migrations
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||||
|
run: |
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_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 }}
|
||||||
|
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
||||||
|
|
||||||
|
# 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: |
|
||||||
|
POSTGREST_HOST_PORT=$(cat .postgrest-port)
|
||||||
|
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||||
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||||
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
|
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||||
|
|
||||||
|
# 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 }}
|
||||||
|
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
||||||
|
|
||||||
|
# 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 "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 "ADMIN_ALLOWED_EMAILS=%s\n" "$ADMIN_ALLOWED_EMAILS"
|
||||||
|
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
|
||||||
|
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
|
||||||
|
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
|
||||||
|
printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY"
|
||||||
|
printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX"
|
||||||
|
printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL"
|
||||||
|
printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT"
|
||||||
|
printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI"
|
||||||
|
printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE"
|
||||||
|
printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
|
||||||
|
printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL"
|
||||||
|
printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY"
|
||||||
|
printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY"
|
||||||
|
printf "STRIPE_WEBHOOK_SECRET=%s\n" "$STRIPE_WEBHOOK_SECRET"
|
||||||
|
printf "STRIPE_PUBLISHABLE_KEY=%s\n" "$STRIPE_PUBLISHABLE_KEY"
|
||||||
|
printf "RESEND_API_KEY=%s\n" "$RESEND_API_KEY"
|
||||||
|
printf "RESEND_WEBHOOK_SECRET=%s\n" "$RESEND_WEBHOOK_SECRET"
|
||||||
|
printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY"
|
||||||
|
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL"
|
||||||
|
printf "FROM_EMAIL=%s\n" "$FROM_EMAIL"
|
||||||
|
} > $APP_DIR/.env.production
|
||||||
|
|
||||||
|
# Copy build output and required files
|
||||||
|
rsync -a --delete .next/ $APP_DIR/.next/
|
||||||
|
rsync -a --delete public/ $APP_DIR/public/
|
||||||
|
cp package.json $APP_DIR/
|
||||||
|
cp 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
|
||||||
|
|
||||||
|
# Install production deps only
|
||||||
|
cd $APP_DIR
|
||||||
|
npm install --omit=dev
|
||||||
|
|
||||||
|
# Start or restart PM2 process
|
||||||
|
if pm2 describe route-commerce > /dev/null 2>&1; then
|
||||||
|
pm2 restart route-commerce
|
||||||
|
else
|
||||||
|
pm2 start npm --name route-commerce -- start -- -p 3100
|
||||||
|
pm2 save
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deployed successfully"
|
||||||
@@ -2,11 +2,23 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
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
|
## 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.
|
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)
|
npx playwright test # Run E2E tests (Playwright)
|
||||||
```
|
```
|
||||||
|
|
||||||
> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL.
|
> 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.
|
||||||
> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
|
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
||||||
> 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.
|
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -36,15 +46,37 @@ No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.
|
|||||||
|
|
||||||
### Authentication & Authorization
|
### Authentication & Authorization
|
||||||
|
|
||||||
**Dev mode** bypasses Supabase auth entirely via `dev_session` cookie set by `/login`:
|
**Auth.js v5 (NextAuth)** is the active auth system. Config lives in `src/lib/auth.ts`, with the route handler at `src/app/api/auth/[...nextauth]/route.ts`. The login page (`src/app/login/LoginClient.tsx`) renders a "Continue with Google" button alongside the email/password form, both backed by Auth.js providers. Server-side code reads the session via `await auth()` from `@/lib/auth`; client code that needs to sign out can call the `signOutAction` server action from `@/actions/auth-actions`.
|
||||||
|
|
||||||
|
**Providers (see `src/lib/auth.ts`):**
|
||||||
|
- **Google OAuth** — primary sign-in. Active when `AUTH_GOOGLE_ID` + `AUTH_GOOGLE_SECRET` are set. Users auto-provision as `platform_admin` if their Google `sub` is UUID-shaped (rare) — for Google sign-ins, `admin_users` rows must be provisioned manually by an existing admin until the `email`-based provisioning flow lands.
|
||||||
|
- **Email/password (Supabase-backed)** — wraps the existing `auth/v1/token?grant_type=password` flow. This is transitional; once Supabase auth is fully removed, this provider goes away.
|
||||||
|
|
||||||
|
**Demo / dev mode** still works through a `dev_session` cookie:
|
||||||
- `dev_session=platform_admin` — full access, all brands
|
- `dev_session=platform_admin` — full access, all brands
|
||||||
- `dev_session=brand_admin` — full access to assigned brand only
|
- `dev_session=brand_admin` — full access to assigned brand only
|
||||||
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
|
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
|
||||||
|
- The login page renders "Demo Mode" buttons that set this cookie client-side; the middleware also auto-issues `dev_session=platform_admin` for the `/admin` demo flow when Supabase isn't configured.
|
||||||
|
|
||||||
`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.
|
**Single source of truth for the current admin user:** `getAdminUser()` in `src/lib/admin-permissions.ts`. It reads `dev_session` first, then the Auth.js session via `auth()` from `@/lib/auth`. The Supabase `admin_users` lookup still uses `rest/v1/admin_users?user_id=eq.<uid>` — when the `pg` pool at `src/lib/db.ts` lands, that lookup should switch to a direct `SELECT`. **Never import `admin-permissions.ts` into Client Components** — use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
|
||||||
|
|
||||||
|
The middleware (`src/middleware.ts`) uses Auth.js v5's `auth()` wrapper. It guards `/admin/*` and `/login`, preserves the `dev_session` bypass, and adds baseline security headers.
|
||||||
|
|
||||||
The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary.
|
The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary.
|
||||||
|
|
||||||
|
#### Migration status
|
||||||
|
|
||||||
|
- ✅ Auth.js v5 installed (`next-auth@beta`, currently `5.0.0-beta.31`)
|
||||||
|
- ✅ `src/lib/auth.ts` + `src/app/api/auth/[...nextauth]/route.ts` in place
|
||||||
|
- ✅ Google + Credentials (Supabase-backed) providers configured
|
||||||
|
- ✅ `getAdminUser()` reads from `auth()` session
|
||||||
|
- ✅ Middleware uses `auth()` wrapper
|
||||||
|
- ✅ Old `/api/login`, `/api/logout`, `/api/auth/uid`, `/api/set-auth-cookie` removed
|
||||||
|
- ⏳ Add `email` column to `admin_users` and provision Google users by email (TODO)
|
||||||
|
- ⏳ Switch the `admin_users` lookup in `getAdminUser()` to direct `pg` (TODO — needs `src/lib/db.ts`)
|
||||||
|
- ⏳ Remove the email/password (Supabase) provider when Supabase auth is fully cut over (TODO)
|
||||||
|
- ⏳ The `rc_auth_uid` cookie is no longer the source of truth, but `actions/admin/users.ts` still reads it for backward compat with pre-existing sessions — the `DEV_FORCE_UID` constant and its branches are now dead code (the `/api/force-admin` route that set it was deleted) and should be removed in a follow-up
|
||||||
|
|
||||||
### Server Actions Pattern
|
### Server Actions Pattern
|
||||||
|
|
||||||
All database writes go through server actions in `src/actions/`. These:
|
All database writes go through server actions in `src/actions/`. These:
|
||||||
@@ -55,9 +87,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.
|
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
|
- 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
|
- Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it
|
||||||
@@ -182,10 +224,19 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
|
|||||||
|
|
||||||
### Communications Module ("Harvest Reach")
|
### 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`.
|
`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
|
### Payments
|
||||||
|
|
||||||
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
|
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
|
||||||
@@ -200,7 +251,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
|||||||
|
|
||||||
## Key Conventions
|
## 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
|
- `gen_random_uuid()` used in migrations for primary keys
|
||||||
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
|
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
|
||||||
- Status enums stored as TEXT — no PostgreSQL ENUM type
|
- Status enums stored as TEXT — no PostgreSQL ENUM type
|
||||||
@@ -217,11 +268,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` |
|
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
|
||||||
| Middleware (route protection) | `src/middleware.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 pages | `src/app/admin/[module]/page.tsx` |
|
||||||
| Admin client components | `src/components/admin/*.tsx` |
|
| Admin client components | `src/components/admin/*.tsx` |
|
||||||
| Migrations | `supabase/migrations/` |
|
| Migrations | `supabase/migrations/` (kept for now; will likely move to `db/migrations/` in a later pass) |
|
||||||
| Supabase client | `src/lib/supabase.ts` |
|
| Postgres pool / driver | `src/lib/db.ts` (TBD — create during the Supabase removal pass) |
|
||||||
| Email templates | `src/lib/email-templates.ts` |
|
| Email templates | `src/lib/email-templates.ts` |
|
||||||
| Date formatting | `src/lib/format-date.ts` |
|
| Date formatting | `src/lib/format-date.ts` |
|
||||||
| Feature flags | `src/lib/feature-flags.ts` |
|
| Feature flags | `src/lib/feature-flags.ts` |
|
||||||
@@ -238,7 +289,8 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
|||||||
## Gotchas
|
## 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.
|
- **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.
|
- **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.
|
- **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`.
|
- **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`.
|
||||||
@@ -2,11 +2,40 @@
|
|||||||
|
|
||||||
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
|
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)
|
### Login + Link (done in this session)
|
||||||
- User ran `supabase login`
|
- User ran `supabase login`
|
||||||
@@ -35,15 +64,23 @@ Key changes:
|
|||||||
- Falls back to direct `pg` only if CLI path fails.
|
- Falls back to direct `pg` only if CLI path fails.
|
||||||
- Header comments updated with current recommended workflow.
|
- 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
|
```bash
|
||||||
|
# Supabase CLI path (legacy — do not use going forward)
|
||||||
supabase login
|
supabase login
|
||||||
supabase link --project-ref wnzkhezyhnfzhkhiflrp
|
supabase link --project-ref wnzkhezyhnfzhkhiflrp
|
||||||
node supabase/push-migrations.js 148 # or any prefix
|
node supabase/push-migrations.js 148 # CLI path
|
||||||
# or
|
# or
|
||||||
npm run migrate:one 148
|
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).
|
`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.
|
- 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.
|
||||||
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project.
|
- 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.)
|
||||||
- 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.
|
- `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.
|
||||||
- Storage policies, RLS, SECURITY DEFINER functions, and brand-scoped data are all over the place — test carefully after big applies.
|
- 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.
|
- 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
|
## How to Use This Memory
|
||||||
|
|
||||||
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
|
- 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.
|
- Update this file with new key facts, applied migrations, or new gotchas.
|
||||||
- Feel free to add dated sections.
|
- Feel free to add dated sections.
|
||||||
|
|
||||||
@@ -245,3 +286,207 @@ Follow-up pass on the original Codex review covering public site, buyer path, bi
|
|||||||
### Migration 203 — applied via Supabase CLI
|
### 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.
|
`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.
|
||||||
|
|
||||||
|
## Build green — 2026-06-06
|
||||||
|
|
||||||
|
Push `32396af` to `origin/main` triggered a successful Gitea deploy. Fixes that landed:
|
||||||
|
- `force-dynamic` on `src/app/admin/layout.tsx` + `src/app/admin/settings/square-sync/page.tsx`
|
||||||
|
- try/catch around Supabase REST fetches in `src/actions/stops.ts` and `src/actions/brand-settings.ts`
|
||||||
|
- `.gitea/workflows/deploy.yml` paths updated to `deploy/docker-compose.yml`
|
||||||
|
|
||||||
|
## Production prep — next steps
|
||||||
|
|
||||||
|
1. **Verify the stack is actually running.** SSH to the deploy host, `docker compose -p prod-app ps` in `$APP_DIR` (`/home/tyler/route-commerce`). All services should be `healthy`.
|
||||||
|
2. **Test Postgres connectivity.** `docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c '\dt'` should list tables from migrations. `curl http://localhost:$POSTGREST_HOST_PORT/` should return PostgREST's OpenAPI spec.
|
||||||
|
3. **Test app → PostgREST.** Hit any public page that reads from PostgREST (e.g. `/indian-river-direct/stops` after the revalidate window). If it returns stops, the chain works.
|
||||||
|
4. **Replace dummy secrets** in Gitea:
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321` + `NEXT_PUBLIC_SUPABASE_ANON_KEY=dummy-supabase-anon-ke` — either set real Supabase project values, or remove entirely once the Postgres-direct migration is complete (CLAUDE.md direction).
|
||||||
|
- `RESEND_API_KEY=re_REPLACE_ME`, `RESEND_WEBHOOK_SECRET=whsec_REPLACE_ME` — get real values from Resend dashboard.
|
||||||
|
- `STRIPE_SECRET_KEY=sk_test_REPLACE_ME`, `STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_ME`, `STRIPE_WEBHOOK_SECRET=whsec_REPLACE_ME` — real test-mode values from Stripe.
|
||||||
|
5. **Supabase → direct Postgres migration.** The codebase still imports `@supabase/ssr` and `@supabase/supabase-js` in `src/lib/supabase.ts`, `src/lib/supabase/server.ts`, `src/actions/login.ts`, `src/actions/admin/users.ts`, `src/actions/admin/force-login.ts`, `src/actions/wholesale-auth.ts`. CLAUDE.md says these should be purged. The deploy stack already has PostgREST, so the path is: replace `supabase.from(...)` calls with `fetch` to `NEXT_PUBLIC_API_URL/rest/v1/...` or direct `pg` queries, then drop the `@supabase/*` deps.
|
||||||
|
6. **Auth.js hardening.** `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` aren't in the secret list — the workflow falls back to `BETTER_AUTH_*` names which exist. Set the canonical `AUTH_*` names too so the fallback isn't load-bearing.
|
||||||
|
|
||||||
|
## Login flow consolidated — 2026-06-06
|
||||||
|
|
||||||
|
Push `e499139` fixes the "dev login redirects back to /login" bug and
|
||||||
|
removes the three-mode login page.
|
||||||
|
|
||||||
|
**Root cause:** `src/middleware.ts` didn't exist, so the `authorized`
|
||||||
|
callback in `auth.config.ts` never ran at the edge. The demo buttons at
|
||||||
|
`/login?demo=1` set `dev_session` via `document.cookie`, but nothing at
|
||||||
|
the edge recognized the cookie — the admin layout's `getAdminUser()` was
|
||||||
|
the only thing reading it, and if the layout's `force-dynamic` ever
|
||||||
|
stopped applying, the user would be bounced.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- **New `src/middleware.ts`** — plain middleware (NOT the `auth()`
|
||||||
|
wrapper). Gates `/admin/*` and `/login`:
|
||||||
|
- If `dev_session`, `rc_auth_uid`, or `rc_uid` cookie is present →
|
||||||
|
`NextResponse.next()`.
|
||||||
|
- If no auth cookie, on `/admin/*`, and `ALLOW_DEV_LOGIN !== "false"`
|
||||||
|
(on by default) → set `dev_session=platform_admin` cookie and
|
||||||
|
`NextResponse.next()`. Invisible auto-login.
|
||||||
|
- If no auth and dev disabled → redirect to `/login`.
|
||||||
|
- If authenticated and on `/login` → redirect to `/admin`.
|
||||||
|
- **`src/app/login/LoginClient.tsx`** — stripped to a single Google
|
||||||
|
OAuth button. Removed:
|
||||||
|
- Email/password form (was hitting dummy Supabase and 500'ing).
|
||||||
|
- Dev credentials form (`signInWithDev`).
|
||||||
|
- `DemoMode` component with the three buttons (Platform Admin,
|
||||||
|
Brand Admin, Store Employee).
|
||||||
|
- `useState`/`useEffect`/`useCallback`/`useSearchParams`/`Suspense`
|
||||||
|
— none of that complexity is needed for a single button.
|
||||||
|
- **`src/actions/auth-signin.ts`** — removed `signInWithDev`. Kept
|
||||||
|
`signInWithGoogle` and `signOutAction`.
|
||||||
|
- **Deleted `src/app/dev-login/page.tsx`** and
|
||||||
|
**`src/app/api/dev-login/route.ts`** — dead routes, middleware
|
||||||
|
handles it.
|
||||||
|
|
||||||
|
**What "one way to log in" looks like now:**
|
||||||
|
- Dev/demo: visit `/admin` → middleware sets `dev_session` cookie →
|
||||||
|
`getAdminUser()` returns platform_admin → you're in.
|
||||||
|
- Production: visit `/admin` → no cookie, `ALLOW_DEV_LOGIN=false` →
|
||||||
|
redirect to `/login` → click Google → Auth.js OAuth flow.
|
||||||
|
|
||||||
|
**Note for Auth.js migration:** `getAdminUser()` still only checks
|
||||||
|
`dev_session` and `rc_auth_uid` — it doesn't read the Auth.js JWT.
|
||||||
|
After Google sign-in succeeds, the user has a valid Auth.js session
|
||||||
|
but `getAdminUser()` returns null. The middleware can't fix that
|
||||||
|
because it can't write to the JWT without going through the
|
||||||
|
credentials provider. This is the next piece of the Auth.js migration
|
||||||
|
(see CLAUDE.md "Auth.js migration — in progress"). The current fix
|
||||||
|
gets the dev/demo path working; the Google OAuth → admin path needs
|
||||||
|
the `getAdminUser()` Auth.js check wired up.
|
||||||
|
|
||||||
|
## Auth.js v5 wiring complete — 2026-06-06
|
||||||
|
|
||||||
|
Push `1e9f9c0` completes the Auth.js path so Google sign-in lands the
|
||||||
|
user on `/admin` as a real admin (not "Your account does not have
|
||||||
|
admin access").
|
||||||
|
|
||||||
|
**What landed:**
|
||||||
|
|
||||||
|
- **`src/lib/db.ts` (NEW)** — shared `pg.Pool` singleton. The single
|
||||||
|
connection pool for the whole app. Extracted from `src/lib/auth.ts`
|
||||||
|
(which had its own private pool). Connection string resolution:
|
||||||
|
`DATABASE_URL` → `SUPABASE_DB_URL` → `POSTGRES_URL`.
|
||||||
|
|
||||||
|
- **`src/lib/auth.ts`** — imports the shared pool. The `signIn` event
|
||||||
|
now calls the new `upsert_admin_user_for_authjs` RPC instead of
|
||||||
|
the no-op existence check it had before.
|
||||||
|
|
||||||
|
- **`supabase/migrations/209_authjs_auto_create_admin.sql` (NEW)** —
|
||||||
|
pushed automatically by the deploy workflow (line 130 of
|
||||||
|
`.gitea/workflows/deploy.yml` does `cat supabase/migrations/[0-9]*.sql
|
||||||
|
| $PG`). Contains:
|
||||||
|
- `ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS
|
||||||
|
can_manage_settings BOOLEAN NOT NULL DEFAULT false` — defensive,
|
||||||
|
since this column is in the TypeScript `AdminUser` type but not
|
||||||
|
in any tracked migration (was likely dashboard-added).
|
||||||
|
- SECURITY DEFINER RPC `upsert_admin_user_for_authjs(p_user_id UUID)`
|
||||||
|
that inserts a `platform_admin` row with all `can_manage_*` flags
|
||||||
|
true, `ON CONFLICT (user_id) DO NOTHING`.
|
||||||
|
- `NOTIFY pgrst, 'reload schema'` so PostgREST picks up the new RPC.
|
||||||
|
|
||||||
|
- **`src/lib/admin-permissions.ts`** — new Auth.js session check
|
||||||
|
between `dev_session` and `rc_auth_uid`. Uses `auth()` from
|
||||||
|
`@/lib/auth` to decrypt the JWT cookie server-side, then
|
||||||
|
`getAdminUserFromPool()` queries `admin_users` + `admin_user_brands`
|
||||||
|
via the shared pool. The legacy `rc_auth_uid` path is unchanged
|
||||||
|
(deferred — it still hits the dummy Supabase URL in prod).
|
||||||
|
|
||||||
|
- **`src/middleware.ts`** — recognizes `authjs.session-token` and
|
||||||
|
`__Secure-authjs.session-token` cookies at the edge so signed-in
|
||||||
|
users aren't bounced to `/login`.
|
||||||
|
|
||||||
|
**Key insight: same ID space.** Both `admin_users.user_id` (UUID, per
|
||||||
|
`028_fix_caller_uid_type.sql`) and Auth.js `users.id` (UUID, per
|
||||||
|
`204_authjs_tables.sql:18`) are in the same UUID space. The
|
||||||
|
`@auth/pg-adapter` auto-generates a fresh UUID per new user on first
|
||||||
|
sign-in; the Google `sub` claim is stored separately in
|
||||||
|
`accounts."providerAccountId"`. So no schema change was needed —
|
||||||
|
just a `user_id` lookup in `getAdminUserFromPool()`.
|
||||||
|
|
||||||
|
**Full sign-in flow now:**
|
||||||
|
|
||||||
|
1. Dev/demo: visit `/admin` → middleware auto-issues `dev_session`
|
||||||
|
cookie → `getAdminUser()` returns platform_admin. (No DB call.)
|
||||||
|
2. Production: click "Sign in with Google" → Auth.js OAuth →
|
||||||
|
`signIn` event fires → `upsert_admin_user_for_authjs` creates
|
||||||
|
the `admin_users` row → redirect to `/admin` → `getAdminUser()`
|
||||||
|
reads JWT, queries pool via `auth.js.user.id`, returns
|
||||||
|
platform_admin.
|
||||||
|
|
||||||
|
**What's still broken (out of scope for this push):**
|
||||||
|
|
||||||
|
- Legacy `rc_auth_uid` path in `getAdminUser()` still fetches from
|
||||||
|
`${NEXT_PUBLIC_SUPABASE_URL}/rest/v1/...` which is a dummy
|
||||||
|
`http://localhost:54321` in prod. Any pre-existing user with a
|
||||||
|
`rc_auth_uid` cookie will get null. Defer until the Supabase →
|
||||||
|
direct Postgres migration of the REST calls.
|
||||||
|
- `getCurrentAdminUser` (client-side variant) still reads from
|
||||||
|
server-passed props — no change needed.
|
||||||
|
- The `signIn` event RPC call will fail silently if `DATABASE_URL`
|
||||||
|
is not set. The user would see "Your account does not have admin
|
||||||
|
access" and need to sign out and back in once the env is fixed.
|
||||||
|
|
||||||
|
## Deploy fix — PostgREST env + dead nextjs service — 2026-06-06
|
||||||
|
|
||||||
|
Push `2d55791` fixes two issues that broke the "Start Docker stack" step:
|
||||||
|
|
||||||
|
1. **`PGRST_DB_URI` not set** — the env var was only in the "Deploy"
|
||||||
|
step's env, which runs after PostgREST has already started.
|
||||||
|
PostgREST booted with a blank DB URI. Now set in the "Start
|
||||||
|
Docker stack" step's env and written to `$APP_DIR/.env` (the
|
||||||
|
file docker compose auto-loads).
|
||||||
|
|
||||||
|
2. **`docker-compose.yml` had a dead `nextjs` service** with
|
||||||
|
`env_file: ../.env.production`. That file is written by the
|
||||||
|
"Deploy" step (later in the workflow), so at "Start Docker stack"
|
||||||
|
time the path doesn't exist. `docker compose up` validates the
|
||||||
|
whole compose file and bailed.
|
||||||
|
|
||||||
|
The `nextjs` service is dead code anyway — PM2 runs Next.js
|
||||||
|
directly from `$APP_DIR`, never through docker. Removed it.
|
||||||
|
|
||||||
|
**Other fixes in the same push:**
|
||||||
|
|
||||||
|
- `docker compose up -d db postgrest minio minio_init` referenced
|
||||||
|
services that don't exist in the compose file. Postgres runs on
|
||||||
|
the host (the migrations step uses `psql -h 127.0.0.1`), not in
|
||||||
|
docker. Changed to just `postgrest`.
|
||||||
|
|
||||||
|
- The `pg_isready` check was `docker compose exec -T db pg_isready`.
|
||||||
|
Since `db` is a host service, changed to
|
||||||
|
`PGPASSWORD=... psql -h 127.0.0.1 -U ... -d ... -c "SELECT 1"`.
|
||||||
|
|
||||||
|
**Architecture (now consistent):**
|
||||||
|
|
||||||
|
- Postgres: host (127.0.0.1:5432), migrations via `psql -h 127.0.0.1`
|
||||||
|
- PostgREST: docker, connects to host Postgres via `PGRST_DB_URI`
|
||||||
|
- Next.js: host, PM2 process, reads `DATABASE_URL` from `.env.production`
|
||||||
|
- MinIO: not yet wired up (the `MINIO_ROOT_USER`/`PASSWORD` env vars
|
||||||
|
are written to `.env` but no service consumes them yet — add a
|
||||||
|
`minio` service to docker-compose.yml when storage goes live)
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# YOLO Auth Migration — Final Report
|
||||||
|
|
||||||
|
**Date:** 2026-06-06 → 2026-06-07
|
||||||
|
**Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out
|
||||||
|
all Supabase references from the auth/admin path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
### 1. Auth.js v5 (NextAuth) integration — DONE
|
||||||
|
|
||||||
|
- **`src/lib/auth.ts`** — Auth.js v5 config. Google is the only provider
|
||||||
|
(no Supabase-backed Credentials provider). Sessions are JWTs; no DB
|
||||||
|
adapter. Exports `handlers`, `auth`, `signIn`, `signOut`.
|
||||||
|
- **`src/lib/admin-permissions.ts`** — `getAdminUser()` resolves the
|
||||||
|
current admin. Three branches in precedence order:
|
||||||
|
1. `NEXT_PUBLIC_USE_MOCK_DATA=true` → platform_admin dev shim
|
||||||
|
2. `dev_session` cookie → matching dev shim (platform/brand/store)
|
||||||
|
3. Auth.js session → `null` (no DB lookup yet — see Follow-ups)
|
||||||
|
- **`src/actions/auth-actions.ts`** — `signInWithGoogle()` and
|
||||||
|
`signOutAction()`. `signInWithPassword` was removed.
|
||||||
|
- **`src/app/login/LoginClient.tsx`** + **`page.tsx`** — Login page
|
||||||
|
renders "Continue with Google" (when configured) and three demo
|
||||||
|
buttons (Platform / Brand / Store Employee) via `?demo=1`. Email /
|
||||||
|
password form and "Forgot password" are gone.
|
||||||
|
- **`src/app/api/auth/[...nextauth]/route.ts`** — Auth.js route handler.
|
||||||
|
|
||||||
|
### 2. Direct Postgres connection (`src/lib/db.ts`) — DONE
|
||||||
|
|
||||||
|
- Single shared `pg.Pool` with lazy initialization. Throws a clear
|
||||||
|
error if `DATABASE_URL` is not set.
|
||||||
|
- Exports `getPool()`, a `pool` proxy alias, `query<T>()` helper, and
|
||||||
|
`withTx()` for transactions.
|
||||||
|
- Tuned for Vercel/serverless: 10 max conns, 30s idle, 10s connect
|
||||||
|
timeout. All overridable via `PG_POOL_*` env vars.
|
||||||
|
|
||||||
|
### 3. Server actions → `pg` (no Supabase) — DONE
|
||||||
|
|
||||||
|
- **`src/actions/admin/users.ts`** — Full rewrite. `getAdminUsers`,
|
||||||
|
`createAdminUser`, `updateAdminUser`, `deleteAdminUser`,
|
||||||
|
`setMustChangePassword`, `getBrands` now hit Postgres directly.
|
||||||
|
`sendPasswordResetEmail` returns a stub error (no auth service
|
||||||
|
anymore — the function is preserved for call-site compatibility).
|
||||||
|
- The `dev_session` cookie continues to be the demo-flow bypass.
|
||||||
|
|
||||||
|
### 4. Cleanup — DONE
|
||||||
|
|
||||||
|
- **`src/actions/admin/force-login.ts`** — Deleted (the
|
||||||
|
Emergency-Force-Login page and its `/api/force-admin` route were
|
||||||
|
removed in the previous session).
|
||||||
|
- **`src/actions/admin/users.ts`** — Removed the `DEV_FORCE_UID`
|
||||||
|
magic value, the `rc_auth_uid` cookie reads (×3), the
|
||||||
|
`headerStore` lookup, the `getServiceClient` / `getAuthClient` /
|
||||||
|
`callRpcWithAuth` helpers, and the `devListAdminUsers` auto-provision
|
||||||
|
branch. All Supabase imports gone.
|
||||||
|
- **`src/app/admin/me/AdminMeClient.tsx`** — Profile / email-change
|
||||||
|
handlers neutralized (return "contact a platform admin" — the
|
||||||
|
Supabase auth backend that backed them is gone).
|
||||||
|
- **`src/middleware.ts`** — `dev_session` auto-login preserved
|
||||||
|
(issues a `platform_admin` cookie on unauthed `/admin` so the rest
|
||||||
|
of the admin shell renders). `auth()` wrapper in place.
|
||||||
|
|
||||||
|
### 5. Test infrastructure — DONE
|
||||||
|
|
||||||
|
- **Vitest 2.1.9** with `@vitejs/plugin-react`, jsdom, manual
|
||||||
|
`@/* → src/*` alias. No `vite-tsconfig-paths` (ESM/require
|
||||||
|
incompatibility).
|
||||||
|
- **`tests/setup.ts`** — Mocks `next/navigation` (not in jsdom).
|
||||||
|
- **`tests/unit/getAdminUser.test.ts`** — 12 tests covering
|
||||||
|
dev_session, mock-data, Auth.js session, defensive error handling.
|
||||||
|
- **`tests/unit/auth-actions.test.ts`** — 2 tests for
|
||||||
|
`signInWithGoogle` + `signOutAction`.
|
||||||
|
- Both test files stub `server-only` with `vi.mock("server-only", () => ({}))`.
|
||||||
|
- **Playwright config** — `webServer` block for `npm run dev`,
|
||||||
|
`PLAYWRIGHT_URL` override, `reuseExistingServer: !process.env.CI`.
|
||||||
|
- **`tests/login/login-flow.spec.ts`** — Login form rendering +
|
||||||
|
demo mode (3 roles) tests.
|
||||||
|
- **`tests/e2e/auth.spec.ts`** — Auth.js endpoints, middleware
|
||||||
|
redirect behavior, /admin load with dev_session, logout.
|
||||||
|
|
||||||
|
### 6. `import "server-only"` markers — DONE
|
||||||
|
|
||||||
|
- Added to `src/lib/auth.ts`, `src/lib/admin-permissions.ts`,
|
||||||
|
`src/lib/db.ts`, `src/actions/auth-actions.ts`,
|
||||||
|
`src/actions/admin/users.ts`.
|
||||||
|
- ⚠️ In `"use server"` files, the directive must be first
|
||||||
|
(`"use server";` on line 1, then `import "server-only";`). The dev
|
||||||
|
build surfaced this; fixed in `auth-actions.ts` and `users.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification status
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `npx tsc --noEmit` | ✅ 0 errors |
|
||||||
|
| `npx vitest run` | ✅ 14/14 tests pass |
|
||||||
|
| `npm run dev` | ✅ Boots in ~440ms |
|
||||||
|
| `GET /login` | ✅ 200 |
|
||||||
|
| `GET /login?demo=1` | ✅ 200 |
|
||||||
|
| `GET /api/auth/providers` | ✅ 200 (empty list — Google not configured) |
|
||||||
|
| `GET /` | ✅ 200 |
|
||||||
|
| `GET /admin` (unauthed) | ✅ 307 → `/admin?demo=1` + `dev_session` cookie |
|
||||||
|
| `GET /admin?demo=1` (with cookie) | ✅ 200 (admin layout renders) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
```
|
||||||
|
M src/actions/admin/users.ts # full rewrite, pg-only
|
||||||
|
M src/actions/auth-actions.ts # signInWithPassword removed
|
||||||
|
M src/app/admin/me/AdminMeClient.tsx # handlers stubbed
|
||||||
|
M src/app/login/LoginClient.tsx # email/password form removed
|
||||||
|
M src/app/login/page.tsx # passes hasGoogle prop
|
||||||
|
M src/lib/admin-permissions.ts # Supabase REST calls removed
|
||||||
|
M src/lib/auth.ts # Credentials provider removed
|
||||||
|
M tests/unit/auth-actions.test.ts # signInWithPassword tests removed
|
||||||
|
M tests/unit/getAdminUser.test.ts # reflects no-DB behavior
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus deletions and creations recorded in the prior session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Follow-ups (the bigger fish)
|
||||||
|
|
||||||
|
The user's directive was: "git rid of all supabase references, we are
|
||||||
|
not using it for login or anything anymore." The auth/admin path is
|
||||||
|
now Supabase-free. **33 files** still import from Supabase — they use
|
||||||
|
it for **data fetching** (`supabase.from(...).select(...)`), not auth.
|
||||||
|
|
||||||
|
**Migrating those to `pg` is a follow-up.** The pattern is the same
|
||||||
|
in every file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Before
|
||||||
|
const { data } = await supabase.from("products").select("*").eq("brand_id", id);
|
||||||
|
|
||||||
|
// After
|
||||||
|
import { query } from "@/lib/db";
|
||||||
|
const { rows } = await query<ProductRow>(
|
||||||
|
"SELECT * FROM products WHERE brand_id = $1",
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 33 files still importing Supabase
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/admin/page.tsx, products/[id]/page.tsx, products/page.tsx,
|
||||||
|
orders/page.tsx, stops/[id]/page.tsx, stops/new/page.tsx,
|
||||||
|
stops/page.tsx, reports/page.tsx, taxes/page.tsx,
|
||||||
|
settings/billing/page.tsx, settings/integrations/page.tsx,
|
||||||
|
settings/shipping/page.tsx
|
||||||
|
src/app/tuxedo/page.tsx, about/page.tsx, contact/ContactClientPage.tsx,
|
||||||
|
stops/[slug]/page.tsx
|
||||||
|
src/app/indian-river-direct/page.tsx, faq/FAQClientPage.tsx,
|
||||||
|
contact/ContactClientPage.tsx, stops/[slug]/page.tsx
|
||||||
|
src/app/api/tuxedo/schedule-pdf/route.ts,
|
||||||
|
src/app/api/indian-river-direct/schedule-pdf/route.ts
|
||||||
|
src/app/reset-password/page.tsx, src/app/test/page.tsx
|
||||||
|
src/components/admin/AdminHeader.tsx, AdminSidebar.tsx
|
||||||
|
src/lib/supabase.ts, src/lib/supabase/server.ts # the client modules
|
||||||
|
src/actions/wholesale-auth.ts, reset-admin.ts, audit.ts
|
||||||
|
src/actions/ai/preferences.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Additional follow-ups
|
||||||
|
|
||||||
|
1. **Provision a `pg` pool in production.** `DATABASE_URL` must be
|
||||||
|
set in the hosting dashboard (same env var as
|
||||||
|
`supabase/push-migrations.js` uses).
|
||||||
|
2. **Apply migration 204** if not already applied — adds
|
||||||
|
`email`, `auth_provider`, `auth_subject` columns to `admin_users`
|
||||||
|
and the `get_admin_user_for_session` RPC.
|
||||||
|
3. **Wire `getAdminUser()`'s Auth.js session branch** to call
|
||||||
|
`get_admin_user_for_session` via `pg` once a `DATABASE_URL` is
|
||||||
|
configured. Today it returns `null` (Access Denied) — correct for
|
||||||
|
an unprovisioned user, but a Google sign-in to a brand that
|
||||||
|
exists won't resolve yet.
|
||||||
|
4. **Drop the `next-auth` Credentials provider module path** in
|
||||||
|
`auth.ts` entirely once production confirms Google is the only
|
||||||
|
provider in use. Currently it's gated on env var presence.
|
||||||
|
5. **Re-implement `sendPasswordResetEmail`** if/when Auth.js v5 ships
|
||||||
|
its built-in email provider — until then, a platform admin must
|
||||||
|
handle resets manually.
|
||||||
|
6. **Decommission `src/lib/supabase.ts` and `src/lib/supabase/server.ts`**
|
||||||
|
once all 33 remaining files have been ported to `pg`. They are
|
||||||
|
the only remaining `@supabase/*` import surface.
|
||||||
|
7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from
|
||||||
|
`package.json`** (last step once no file imports them).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Dev server (boots in ~440ms)
|
||||||
|
npm test # Vitest: 14/14 pass
|
||||||
|
npx tsc --noEmit # Typecheck: 0 errors
|
||||||
|
npx playwright test # E2E (skips credentials tests if env unset)
|
||||||
|
```
|
||||||
Binary file not shown.
+134
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Drizzle client + tenant-scoped query helper.
|
||||||
|
*
|
||||||
|
* The app connects to Postgres directly via the `pg` driver. Drizzle sits
|
||||||
|
* on top, providing typed queries. The `withTenant` wrapper is the only
|
||||||
|
* sanctioned way to run a tenant-scoped query — it sets the
|
||||||
|
* `app.current_tenant_id` GUC transaction-locally, and the database's
|
||||||
|
* RLS policies enforce tenant isolation even if application code forgets
|
||||||
|
* a `WHERE tenant_id = $1`.
|
||||||
|
*
|
||||||
|
* Usage (read):
|
||||||
|
* const products = await withTenant(tenantId, (db) =>
|
||||||
|
* db.select().from(productsTable).where(eq(productsTable.active, true)),
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* Usage (platform admin — sees all tenants):
|
||||||
|
* const allTenants = await withPlatformAdmin((db) =>
|
||||||
|
* db.select().from(tenantsTable),
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* Usage (no tenant — for the rare case the query isn't tenant-scoped):
|
||||||
|
* const plans = await withDb((db) => db.select().from(plansTable));
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "server-only";
|
||||||
|
import { Pool, type PoolClient } from "pg";
|
||||||
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
type Schema = typeof schema;
|
||||||
|
export type Db = NodePgDatabase<Schema>;
|
||||||
|
|
||||||
|
let _pool: Pool | null = null;
|
||||||
|
|
||||||
|
function getPool(): Pool {
|
||||||
|
if (_pool) return _pool;
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error(
|
||||||
|
"DATABASE_URL is not set. Add it to .env.local (see .env.example).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_pool = new Pool({
|
||||||
|
connectionString,
|
||||||
|
max: parseInt(process.env.PG_POOL_MAX ?? "10", 10),
|
||||||
|
idleTimeoutMillis: parseInt(process.env.PG_POOL_IDLE_MS ?? "30000", 10),
|
||||||
|
connectionTimeoutMillis: parseInt(
|
||||||
|
process.env.PG_POOL_CONN_TIMEOUT_MS ?? "10000",
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
allowExitOnIdle: false,
|
||||||
|
});
|
||||||
|
_pool.on("error", (err) => {
|
||||||
|
console.error("[db] idle client error", err);
|
||||||
|
});
|
||||||
|
return _pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` with a Drizzle client. No tenant context is set — the caller
|
||||||
|
* is responsible for RLS bypass (e.g. for the `plans` and `add_ons` tables,
|
||||||
|
* which are not tenant-scoped). For tenant-scoped reads, prefer
|
||||||
|
* `withTenant` or `withPlatformAdmin`.
|
||||||
|
*/
|
||||||
|
export async function withDb<T>(fn: (db: Db) => Promise<T>): Promise<T> {
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
const db = drizzle(client, { schema });
|
||||||
|
return await fn(db);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` inside a transaction with the current tenant id set as a
|
||||||
|
* transaction-local GUC. RLS policies on tenant-scoped tables will allow
|
||||||
|
* reads/writes only for rows where `tenant_id` matches. Pass `null` to
|
||||||
|
* fail open (don't set the GUC) — only useful for the migrations
|
||||||
|
* themselves, never for app code.
|
||||||
|
*/
|
||||||
|
export async function withTenant<T>(
|
||||||
|
tenantId: string,
|
||||||
|
fn: (db: Db) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return runInTransaction(async (client) => {
|
||||||
|
// set_config(setting, value, is_local) — is_local=true makes it
|
||||||
|
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never
|
||||||
|
// leaks across pooled connections.
|
||||||
|
await client.query("SELECT set_config('app.current_tenant_id', $1, true)", [
|
||||||
|
tenantId,
|
||||||
|
]);
|
||||||
|
await client.query("SELECT set_config('app.platform_admin', 'false', true)");
|
||||||
|
const db = drizzle(client, { schema });
|
||||||
|
return fn(db);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` as platform admin. RLS policies permit access to all tenants.
|
||||||
|
* Use sparingly — typically only in the /admin/platform routes.
|
||||||
|
*/
|
||||||
|
export async function withPlatformAdmin<T>(
|
||||||
|
fn: (db: Db) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return runInTransaction(async (client) => {
|
||||||
|
await client.query("SELECT set_config('app.platform_admin', 'true', true)");
|
||||||
|
const db = drizzle(client, { schema });
|
||||||
|
return fn(db);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runInTransaction<T>(
|
||||||
|
fn: (client: PoolClient) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const result = await fn(client);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
// ignore secondary rollback failure
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { schema };
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
-- 0001_init.sql
|
||||||
|
--
|
||||||
|
-- Route Commerce SaaS schema. Single migration, idempotent, no DROP.
|
||||||
|
-- Follows CLAUDE.md conventions:
|
||||||
|
-- - Status enums as TEXT with CHECK (no PG ENUM type)
|
||||||
|
-- - TIMESTAMPTZ everywhere
|
||||||
|
-- - gen_random_uuid() for PKs
|
||||||
|
-- - CREATE OR REPLACE FUNCTION for helpers
|
||||||
|
--
|
||||||
|
-- Multi-tenancy: every business table has `tenant_id` and an RLS policy
|
||||||
|
-- that compares it to `current_setting('app.current_tenant_id')`. The
|
||||||
|
-- application MUST set this GUC (transaction-local) before any query
|
||||||
|
-- against a tenant-scoped table. See `db/client.ts` for the wrapper.
|
||||||
|
--
|
||||||
|
-- Platform admin (sees all tenants) sets `app.platform_admin = 'true'`
|
||||||
|
-- instead. RLS policies allow that to bypass the tenant filter.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 0. Extensions
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Tenancy + auth
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'trial'
|
||||||
|
CHECK (status IN ('trial', 'active', 'past_due', 'suspended', 'churned')),
|
||||||
|
trial_ends_at TIMESTAMPTZ,
|
||||||
|
stripe_customer_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
name TEXT,
|
||||||
|
image TEXT,
|
||||||
|
auth_provider TEXT NOT NULL DEFAULT 'dev'
|
||||||
|
CHECK (auth_provider IN ('dev', 'google', 'email')),
|
||||||
|
auth_subject TEXT,
|
||||||
|
email_verified_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS users_auth_subject_idx
|
||||||
|
ON users (auth_provider, auth_subject)
|
||||||
|
WHERE auth_subject IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_users (
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'brand_admin'
|
||||||
|
CHECK (role IN ('platform_admin', 'brand_admin', 'store_employee')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (tenant_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS tenant_users_user_idx ON tenant_users (user_id);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. Billing
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code TEXT NOT NULL UNIQUE
|
||||||
|
CHECK (code IN ('starter', 'farm', 'enterprise')),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
monthly_price_cents INTEGER NOT NULL CHECK (monthly_price_cents >= 0),
|
||||||
|
max_users INTEGER NOT NULL,
|
||||||
|
max_products INTEGER NOT NULL,
|
||||||
|
max_stops_monthly INTEGER NOT NULL,
|
||||||
|
features JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS add_ons (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code TEXT NOT NULL UNIQUE
|
||||||
|
CHECK (code IN (
|
||||||
|
'wholesale_portal',
|
||||||
|
'harvest_reach',
|
||||||
|
'ai_tools',
|
||||||
|
'water_log',
|
||||||
|
'square_sync',
|
||||||
|
'sms_campaigns'
|
||||||
|
)),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
monthly_price_cents INTEGER NOT NULL CHECK (monthly_price_cents >= 0),
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
plan_id UUID NOT NULL REFERENCES plans(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'trialing'
|
||||||
|
CHECK (status IN ('trialing', 'active', 'past_due', 'canceled', 'incomplete')),
|
||||||
|
stripe_subscription_id TEXT,
|
||||||
|
current_period_end TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_add_ons (
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
add_on_id UUID NOT NULL REFERENCES add_ons(id) ON DELETE CASCADE,
|
||||||
|
stripe_subscription_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK (status IN ('active', 'canceled')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (tenant_id, add_on_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Products
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
|
||||||
|
inventory INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unit TEXT,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS products_tenant_idx ON products (tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS products_active_idx ON products (tenant_id, active);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_images (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
storage_key TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
alt_text TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS product_images_product_idx
|
||||||
|
ON product_images (product_id, position);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. Stops
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stops (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
schedule JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK (status IN ('active', 'paused', 'closed')),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS stops_tenant_idx ON stops (tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS stops_status_idx ON stops (tenant_id, status);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. Customers
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
sms_opt_in BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
email_opt_in BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS customers_tenant_idx ON customers (tenant_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS customers_tenant_email_idx
|
||||||
|
ON customers (tenant_id, email)
|
||||||
|
WHERE email IS NOT NULL;
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 6. Orders
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
|
total_cents INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'confirmed', 'fulfilled', 'canceled')),
|
||||||
|
fulfillment TEXT NOT NULL
|
||||||
|
CHECK (fulfillment IN ('pickup', 'ship', 'mixed')),
|
||||||
|
notes TEXT,
|
||||||
|
placed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS orders_tenant_idx ON orders (tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS orders_customer_idx ON orders (customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (tenant_id, status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS order_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES products(id) ON DELETE SET NULL,
|
||||||
|
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||||
|
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
|
||||||
|
fulfillment TEXT NOT NULL
|
||||||
|
CHECK (fulfillment IN ('pickup', 'ship'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS order_items_order_idx ON order_items (order_id);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 7. Brand settings
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS brand_settings (
|
||||||
|
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
brand_name TEXT NOT NULL,
|
||||||
|
tagline TEXT,
|
||||||
|
about_html TEXT,
|
||||||
|
primary_color TEXT DEFAULT '#0F766E',
|
||||||
|
logo_storage_key TEXT,
|
||||||
|
hero_storage_key TEXT,
|
||||||
|
contact_email TEXT,
|
||||||
|
contact_phone TEXT,
|
||||||
|
custom_footer_text TEXT,
|
||||||
|
feature_flags JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 8. Marketing
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS email_templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
body_html TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS email_templates_tenant_idx ON email_templates (tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS campaigns (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
template_id UUID REFERENCES email_templates(id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft'
|
||||||
|
CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'canceled')),
|
||||||
|
scheduled_for TIMESTAMPTZ,
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
recipient_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS campaigns_tenant_idx ON campaigns (tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS campaigns_status_idx ON campaigns (tenant_id, status);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 9. Files
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
storage_key TEXT NOT NULL UNIQUE,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
purpose TEXT,
|
||||||
|
uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS files_tenant_idx ON files (tenant_id);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 10. Audit log
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target_type TEXT,
|
||||||
|
target_id UUID,
|
||||||
|
payload JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_log_tenant_idx
|
||||||
|
ON audit_log (tenant_id, created_at DESC);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 11. RLS helpers
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION current_tenant_id()
|
||||||
|
RETURNS UUID
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::UUID;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION is_platform_admin()
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE(NULLIF(current_setting('app.platform_admin', true), ''), 'false') = 'true';
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 12. Enable RLS on tenant-scoped tables
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE product_images ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE stops ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE order_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE brand_settings ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE email_templates ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- RLS does not apply to the table owner by default. We FORCE it so the
|
||||||
|
-- application (which connects as the same user that owns the tables in
|
||||||
|
-- dev) cannot accidentally bypass tenant isolation. In production, the
|
||||||
|
-- application should connect as a non-owner role; this is belt-and-
|
||||||
|
-- suspenders for the dev/local case.
|
||||||
|
ALTER TABLE products FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE product_images FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE stops FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE customers FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE order_items FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE brand_settings FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE email_templates FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE campaigns FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE files FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE audit_log FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 13. RLS policies: tenant match OR platform admin
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Drop existing policies (idempotent — same migration may be re-applied)
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON products;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON product_images;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON stops;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON customers;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON orders;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON order_items;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON brand_settings;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON email_templates;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON campaigns;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON files;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON audit_log;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON products
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON product_images
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM products p
|
||||||
|
WHERE p.id = product_images.product_id
|
||||||
|
AND (p.tenant_id = current_tenant_id() OR is_platform_admin())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM products p
|
||||||
|
WHERE p.id = product_images.product_id
|
||||||
|
AND (p.tenant_id = current_tenant_id() OR is_platform_admin())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON stops
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON customers
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON orders
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON order_items
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM orders o
|
||||||
|
WHERE o.id = order_items.order_id
|
||||||
|
AND (o.tenant_id = current_tenant_id() OR is_platform_admin())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM orders o
|
||||||
|
WHERE o.id = order_items.order_id
|
||||||
|
AND (o.tenant_id = current_tenant_id() OR is_platform_admin())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON brand_settings
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON email_templates
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON campaigns
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON files
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL)
|
||||||
|
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL);
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON audit_log
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL)
|
||||||
|
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 14. updated_at triggers
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tenants_updated_at ON tenants;
|
||||||
|
CREATE TRIGGER tenants_updated_at BEFORE UPDATE ON tenants
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS users_updated_at ON users;
|
||||||
|
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS subscriptions_updated_at ON subscriptions;
|
||||||
|
CREATE TRIGGER subscriptions_updated_at BEFORE UPDATE ON subscriptions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS products_updated_at ON products;
|
||||||
|
CREATE TRIGGER products_updated_at BEFORE UPDATE ON products
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS stops_updated_at ON stops;
|
||||||
|
CREATE TRIGGER stops_updated_at BEFORE UPDATE ON stops
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS customers_updated_at ON customers;
|
||||||
|
CREATE TRIGGER customers_updated_at BEFORE UPDATE ON customers
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS orders_updated_at ON orders;
|
||||||
|
CREATE TRIGGER orders_updated_at BEFORE UPDATE ON orders
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS brand_settings_updated_at ON brand_settings;
|
||||||
|
CREATE TRIGGER brand_settings_updated_at BEFORE UPDATE ON brand_settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS email_templates_updated_at ON email_templates;
|
||||||
|
CREATE TRIGGER email_templates_updated_at BEFORE UPDATE ON email_templates
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS campaigns_updated_at ON campaigns;
|
||||||
|
CREATE TRIGGER campaigns_updated_at BEFORE UPDATE ON campaigns
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- 0002_admin_password.sql
|
||||||
|
--
|
||||||
|
-- Adds a `password_hash` column to `users` so the Auth.js Credentials
|
||||||
|
-- provider can verify email + password against the database.
|
||||||
|
--
|
||||||
|
-- Idempotent: uses `ADD COLUMN IF NOT EXISTS`. The column is nullable
|
||||||
|
-- because OAuth-only users (Google) never set a password.
|
||||||
|
--
|
||||||
|
-- The Credentials provider's `authorize` function is documented in
|
||||||
|
-- `src/lib/auth.ts` — it queries this column and runs `verifyPassword`
|
||||||
|
-- (see `src/lib/passwords.ts`) before returning the user.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS password_hash TEXT;
|
||||||
|
|
||||||
|
-- Update the updated_at trigger tracking — no new triggers needed since
|
||||||
|
-- `users` already has `set_updated_at` from migration 0001.
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Audit log. Source of truth: `db/migrations/0001_init.sql`.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
jsonb,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { tenants } from "./tenants";
|
||||||
|
import { users } from "./tenants";
|
||||||
|
|
||||||
|
export const auditLog = pgTable(
|
||||||
|
"audit_log",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tenantId: uuid("tenant_id").references(() => tenants.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
userId: uuid("user_id").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
action: text("action").notNull(),
|
||||||
|
targetType: text("target_type"),
|
||||||
|
targetId: uuid("target_id"),
|
||||||
|
payload: jsonb("payload"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
tenantIdx: index("audit_log_tenant_idx").on(t.tenantId, t.createdAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AuditLog = typeof auditLog.$inferSelect;
|
||||||
|
export type NewAuditLog = typeof auditLog.$inferInsert;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Billing tables: plans, add-ons, subscriptions, tenant_add_ons.
|
||||||
|
* Source of truth: `db/migrations/0001_init.sql`.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
primaryKey,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import {
|
||||||
|
planCodeEnum,
|
||||||
|
addOnCodeEnum,
|
||||||
|
subscriptionStatusEnum,
|
||||||
|
addOnStatusEnum,
|
||||||
|
} from "./enums";
|
||||||
|
import { tenants } from "./tenants";
|
||||||
|
|
||||||
|
export const plans = pgTable("plans", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
code: text("code", { enum: planCodeEnum }).notNull().unique(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
monthlyPriceCents: integer("monthly_price_cents").notNull(),
|
||||||
|
maxUsers: integer("max_users").notNull(),
|
||||||
|
maxProducts: integer("max_products").notNull(),
|
||||||
|
maxStopsMonthly: integer("max_stops_monthly").notNull(),
|
||||||
|
features: jsonb("features").notNull().default([]),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addOns = pgTable("add_ons", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
code: text("code", { enum: addOnCodeEnum }).notNull().unique(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
monthlyPriceCents: integer("monthly_price_cents").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const subscriptions = pgTable("subscriptions", {
|
||||||
|
tenantId: uuid("tenant_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
planId: uuid("plan_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => plans.id),
|
||||||
|
status: text("status", { enum: subscriptionStatusEnum })
|
||||||
|
.notNull()
|
||||||
|
.default("trialing"),
|
||||||
|
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||||
|
currentPeriodEnd: timestamp("current_period_end", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tenantAddOns = pgTable(
|
||||||
|
"tenant_add_ons",
|
||||||
|
{
|
||||||
|
tenantId: uuid("tenant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
addOnId: uuid("add_on_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => addOns.id, { onDelete: "cascade" }),
|
||||||
|
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||||
|
status: text("status", { enum: addOnStatusEnum })
|
||||||
|
.notNull()
|
||||||
|
.default("active"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
pk: primaryKey({ columns: [t.tenantId, t.addOnId] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Plan = typeof plans.$inferSelect;
|
||||||
|
export type NewPlan = typeof plans.$inferInsert;
|
||||||
|
export type AddOn = typeof addOns.$inferSelect;
|
||||||
|
export type NewAddOn = typeof addOns.$inferInsert;
|
||||||
|
export type Subscription = typeof subscriptions.$inferSelect;
|
||||||
|
export type NewSubscription = typeof subscriptions.$inferInsert;
|
||||||
|
export type TenantAddOn = typeof tenantAddOns.$inferSelect;
|
||||||
|
export type NewTenantAddOn = typeof tenantAddOns.$inferInsert;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Brand settings. One row per tenant. Source of truth:
|
||||||
|
* `db/migrations/0001_init.sql`.
|
||||||
|
*/
|
||||||
|
import { pgTable, uuid, text, jsonb, timestamp } from "drizzle-orm/pg-core";
|
||||||
|
import { tenants } from "./tenants";
|
||||||
|
|
||||||
|
export const brandSettings = pgTable("brand_settings", {
|
||||||
|
tenantId: uuid("tenant_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
brandName: text("brand_name").notNull(),
|
||||||
|
tagline: text("tagline"),
|
||||||
|
aboutHtml: text("about_html"),
|
||||||
|
primaryColor: text("primary_color").default("#0F766E"),
|
||||||
|
logoStorageKey: text("logo_storage_key"),
|
||||||
|
heroStorageKey: text("hero_storage_key"),
|
||||||
|
contactEmail: text("contact_email"),
|
||||||
|
contactPhone: text("contact_phone"),
|
||||||
|
customFooterText: text("custom_footer_text"),
|
||||||
|
featureFlags: jsonb("feature_flags").notNull().default({}),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BrandSettings = typeof brandSettings.$inferSelect;
|
||||||
|
export type NewBrandSettings = typeof brandSettings.$inferInsert;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Customers. Source of truth: `db/migrations/0001_init.sql`.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
boolean,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { tenants } from "./tenants";
|
||||||
|
|
||||||
|
export const customers = pgTable(
|
||||||
|
"customers",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tenantId: uuid("tenant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
email: text("email"),
|
||||||
|
phone: text("phone"),
|
||||||
|
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
||||||
|
emailOptIn: boolean("email_opt_in").notNull().default(true),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
tenantIdx: index("customers_tenant_idx").on(t.tenantId),
|
||||||
|
emailIdx: uniqueIndex("customers_tenant_email_idx")
|
||||||
|
.on(t.tenantId, t.email)
|
||||||
|
.where(sql`${t.email} IS NOT NULL`),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Customer = typeof customers.$inferSelect;
|
||||||
|
export type NewCustomer = typeof customers.$inferInsert;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Shared enums for the SaaS schema. Mirrored in SQL as TEXT + CHECK.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { tenantStatusEnum, type TenantStatus } from "@/db/schema/enums";
|
||||||
|
* import { pgEnum } from "drizzle-orm/pg-core";
|
||||||
|
*
|
||||||
|
* export const tenantStatus = pgEnum("tenant_status", tenantStatusEnum);
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const tenantStatusEnum = [
|
||||||
|
"trial",
|
||||||
|
"active",
|
||||||
|
"past_due",
|
||||||
|
"suspended",
|
||||||
|
"churned",
|
||||||
|
] as const;
|
||||||
|
export type TenantStatus = (typeof tenantStatusEnum)[number];
|
||||||
|
|
||||||
|
export const authProviderEnum = ["dev", "google", "email"] as const;
|
||||||
|
export type AuthProvider = (typeof authProviderEnum)[number];
|
||||||
|
|
||||||
|
export const roleEnum = ["platform_admin", "brand_admin", "store_employee"] as const;
|
||||||
|
export type Role = (typeof roleEnum)[number];
|
||||||
|
|
||||||
|
export const planCodeEnum = ["starter", "farm", "enterprise"] as const;
|
||||||
|
export type PlanCode = (typeof planCodeEnum)[number];
|
||||||
|
|
||||||
|
export const addOnCodeEnum = [
|
||||||
|
"wholesale_portal",
|
||||||
|
"harvest_reach",
|
||||||
|
"ai_tools",
|
||||||
|
"water_log",
|
||||||
|
"square_sync",
|
||||||
|
"sms_campaigns",
|
||||||
|
] as const;
|
||||||
|
export type AddOnCode = (typeof addOnCodeEnum)[number];
|
||||||
|
|
||||||
|
export const subscriptionStatusEnum = [
|
||||||
|
"trialing",
|
||||||
|
"active",
|
||||||
|
"past_due",
|
||||||
|
"canceled",
|
||||||
|
"incomplete",
|
||||||
|
] as const;
|
||||||
|
export type SubscriptionStatus = (typeof subscriptionStatusEnum)[number];
|
||||||
|
|
||||||
|
export const addOnStatusEnum = ["active", "canceled"] as const;
|
||||||
|
export type AddOnStatus = (typeof addOnStatusEnum)[number];
|
||||||
|
|
||||||
|
export const stopStatusEnum = ["active", "paused", "closed"] as const;
|
||||||
|
export type StopStatus = (typeof stopStatusEnum)[number];
|
||||||
|
|
||||||
|
export const orderStatusEnum = [
|
||||||
|
"pending",
|
||||||
|
"confirmed",
|
||||||
|
"fulfilled",
|
||||||
|
"canceled",
|
||||||
|
] as const;
|
||||||
|
export type OrderStatus = (typeof orderStatusEnum)[number];
|
||||||
|
|
||||||
|
export const fulfillmentEnum = ["pickup", "ship", "mixed"] as const;
|
||||||
|
export type Fulfillment = (typeof fulfillmentEnum)[number];
|
||||||
|
|
||||||
|
export const itemFulfillmentEnum = ["pickup", "ship"] as const;
|
||||||
|
export type ItemFulfillment = (typeof itemFulfillmentEnum)[number];
|
||||||
|
|
||||||
|
export const campaignStatusEnum = [
|
||||||
|
"draft",
|
||||||
|
"scheduled",
|
||||||
|
"sending",
|
||||||
|
"sent",
|
||||||
|
"canceled",
|
||||||
|
] as const;
|
||||||
|
export type CampaignStatus = (typeof campaignStatusEnum)[number];
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Files. Source of truth: `db/migrations/0001_init.sql`.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
bigint,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { tenants } from "./tenants";
|
||||||
|
import { users } from "./tenants";
|
||||||
|
|
||||||
|
export const files = pgTable(
|
||||||
|
"files",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tenantId: uuid("tenant_id").references(() => tenants.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
storageKey: text("storage_key").notNull().unique(),
|
||||||
|
mimeType: text("mime_type").notNull(),
|
||||||
|
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
|
||||||
|
purpose: text("purpose"),
|
||||||
|
uploadedBy: uuid("uploaded_by").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
tenantIdx: index("files_tenant_idx").on(t.tenantId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type File = typeof files.$inferSelect;
|
||||||
|
export type NewFile = typeof files.$inferInsert;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Schema barrel. Re-exports every Drizzle table + inferred row type.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { products, type Product } from "@/db/schema";
|
||||||
|
*/
|
||||||
|
export * from "./enums";
|
||||||
|
export * from "./tenants";
|
||||||
|
export * from "./billing";
|
||||||
|
export * from "./products";
|
||||||
|
export * from "./stops";
|
||||||
|
export * from "./customers";
|
||||||
|
export * from "./orders";
|
||||||
|
export * from "./brand";
|
||||||
|
export * from "./marketing";
|
||||||
|
export * from "./files";
|
||||||
|
export * from "./audit";
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Marketing: email templates + campaigns.
|
||||||
|
* Source of truth: `db/migrations/0001_init.sql`.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { campaignStatusEnum } from "./enums";
|
||||||
|
import { tenants } from "./tenants";
|
||||||
|
|
||||||
|
export const emailTemplates = pgTable(
|
||||||
|
"email_templates",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tenantId: uuid("tenant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
subject: text("subject").notNull(),
|
||||||
|
bodyHtml: text("body_html").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
tenantIdx: index("email_templates_tenant_idx").on(t.tenantId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const campaigns = pgTable(
|
||||||
|
"campaigns",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tenantId: uuid("tenant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
templateId: uuid("template_id").references((): any => emailTemplates.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
status: text("status", { enum: campaignStatusEnum })
|
||||||
|
.notNull()
|
||||||
|
.default("draft"),
|
||||||
|
scheduledFor: timestamp("scheduled_for", { withTimezone: true }),
|
||||||
|
sentAt: timestamp("sent_at", { withTimezone: true }),
|
||||||
|
recipientCount: integer("recipient_count").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
tenantIdx: index("campaigns_tenant_idx").on(t.tenantId),
|
||||||
|
statusIdx: index("campaigns_status_idx").on(t.tenantId, t.status),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type EmailTemplate = typeof emailTemplates.$inferSelect;
|
||||||
|
export type NewEmailTemplate = typeof emailTemplates.$inferInsert;
|
||||||
|
export type Campaign = typeof campaigns.$inferSelect;
|
||||||
|
export type NewCampaign = typeof campaigns.$inferInsert;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Orders + order_items. Source of truth: `db/migrations/0001_init.sql`.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { orderStatusEnum, fulfillmentEnum, itemFulfillmentEnum } from "./enums";
|
||||||
|
import { tenants } from "./tenants";
|
||||||
|
import { customers } from "./customers";
|
||||||
|
import { products } from "./products";
|
||||||
|
|
||||||
|
export const orders = pgTable(
|
||||||
|
"orders",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tenantId: uuid("tenant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
customerId: uuid("customer_id").references(() => customers.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
totalCents: integer("total_cents").notNull().default(0),
|
||||||
|
status: text("status", { enum: orderStatusEnum }).notNull().default("pending"),
|
||||||
|
fulfillment: text("fulfillment", { enum: fulfillmentEnum }).notNull(),
|
||||||
|
notes: text("notes"),
|
||||||
|
placedAt: timestamp("placed_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
tenantIdx: index("orders_tenant_idx").on(t.tenantId),
|
||||||
|
customerIdx: index("orders_customer_idx").on(t.customerId),
|
||||||
|
statusIdx: index("orders_status_idx").on(t.tenantId, t.status),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const orderItems = pgTable(
|
||||||
|
"order_items",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
orderId: uuid("order_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orders.id, { onDelete: "cascade" }),
|
||||||
|
productId: uuid("product_id").references(() => products.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
quantity: integer("quantity").notNull(),
|
||||||
|
priceCents: integer("price_cents").notNull(),
|
||||||
|
fulfillment: text("fulfillment", { enum: itemFulfillmentEnum }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
orderIdx: index("order_items_order_idx").on(t.orderId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Order = typeof orders.$inferSelect;
|
||||||
|
export type NewOrder = typeof orders.$inferInsert;
|
||||||
|
export type OrderItem = typeof orderItems.$inferSelect;
|
||||||
|
export type NewOrderItem = typeof orderItems.$inferInsert;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Products + product_images. Source of truth: `db/migrations/0001_init.sql`.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
boolean,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { tenants } from "./tenants";
|
||||||
|
|
||||||
|
export const products = pgTable(
|
||||||
|
"products",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tenantId: uuid("tenant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
priceCents: integer("price_cents").notNull(),
|
||||||
|
inventory: integer("inventory").notNull().default(0),
|
||||||
|
unit: text("unit"),
|
||||||
|
active: boolean("active").notNull().default(true),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
tenantIdx: index("products_tenant_idx").on(t.tenantId),
|
||||||
|
activeIdx: index("products_active_idx").on(t.tenantId, t.active),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const productImages = pgTable(
|
||||||
|
"product_images",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
productId: uuid("product_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.id, { onDelete: "cascade" }),
|
||||||
|
storageKey: text("storage_key").notNull(),
|
||||||
|
position: integer("position").notNull().default(0),
|
||||||
|
altText: text("alt_text"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
productIdx: index("product_images_product_idx").on(t.productId, t.position),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Product = typeof products.$inferSelect;
|
||||||
|
export type NewProduct = typeof products.$inferInsert;
|
||||||
|
export type ProductImage = typeof productImages.$inferSelect;
|
||||||
|
export type NewProductImage = typeof productImages.$inferInsert;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Stops. Source of truth: `db/migrations/0001_init.sql`.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
jsonb,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { stopStatusEnum } from "./enums";
|
||||||
|
import { tenants } from "./tenants";
|
||||||
|
|
||||||
|
export const stops = pgTable(
|
||||||
|
"stops",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tenantId: uuid("tenant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
address: text("address").notNull(),
|
||||||
|
schedule: jsonb("schedule").notNull().default([]),
|
||||||
|
status: text("status", { enum: stopStatusEnum }).notNull().default("active"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
tenantIdx: index("stops_tenant_idx").on(t.tenantId),
|
||||||
|
statusIdx: index("stops_status_idx").on(t.tenantId, t.status),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Stop = typeof stops.$inferSelect;
|
||||||
|
export type NewStop = typeof stops.$inferInsert;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Tenancy + auth tables. Source of truth: `db/migrations/0001_init.sql`.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
primaryKey,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
tenantStatusEnum,
|
||||||
|
authProviderEnum,
|
||||||
|
roleEnum,
|
||||||
|
} from "./enums";
|
||||||
|
|
||||||
|
export const tenants = pgTable("tenants", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
slug: text("slug").notNull().unique(),
|
||||||
|
status: text("status", { enum: tenantStatusEnum }).notNull().default("trial"),
|
||||||
|
trialEndsAt: timestamp("trial_ends_at", { withTimezone: true }),
|
||||||
|
stripeCustomerId: text("stripe_customer_id"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const users = pgTable(
|
||||||
|
"users",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
email: text("email").unique(),
|
||||||
|
name: text("name"),
|
||||||
|
image: text("image"),
|
||||||
|
/**
|
||||||
|
* Bcrypt / scrypt / argon2 hash, or null for OAuth-only users.
|
||||||
|
* Format is self-describing (algorithm$N$salt$hash) so we can
|
||||||
|
* migrate to a stronger KDF later without losing existing hashes.
|
||||||
|
* See `src/lib/passwords.ts` for the encoder/decoder.
|
||||||
|
*/
|
||||||
|
passwordHash: text("password_hash"),
|
||||||
|
authProvider: text("auth_provider", { enum: authProviderEnum })
|
||||||
|
.notNull()
|
||||||
|
.default("dev"),
|
||||||
|
authSubject: text("auth_subject"),
|
||||||
|
emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
authSubjectIdx: uniqueIndex("users_auth_subject_idx")
|
||||||
|
.on(t.authProvider, t.authSubject)
|
||||||
|
.where(sql`${t.authSubject} IS NOT NULL`),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const tenantUsers = pgTable(
|
||||||
|
"tenant_users",
|
||||||
|
{
|
||||||
|
tenantId: uuid("tenant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
role: text("role", { enum: roleEnum }).notNull().default("brand_admin"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
pk: primaryKey({ columns: [t.tenantId, t.userId] }),
|
||||||
|
userIdx: index("tenant_users_user_idx").on(t.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Tenant = typeof tenants.$inferSelect;
|
||||||
|
export type NewTenant = typeof tenants.$inferInsert;
|
||||||
|
export type User = typeof users.$inferSelect;
|
||||||
|
export type NewUser = typeof users.$inferInsert;
|
||||||
|
export type TenantUser = typeof tenantUsers.$inferSelect;
|
||||||
|
export type NewTenantUser = typeof tenantUsers.$inferInsert;
|
||||||
+314
@@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* Seed script. Idempotent — safe to re-run. Uses `ON CONFLICT (col) DO UPDATE`
|
||||||
|
* (with a target) for tables that have a unique constraint; uses
|
||||||
|
* `WHERE NOT EXISTS` for tables that don't.
|
||||||
|
*
|
||||||
|
* npm run db:seed
|
||||||
|
*
|
||||||
|
* Populates:
|
||||||
|
* - 3 plans (Starter / Farm / Enterprise)
|
||||||
|
* - 6 add-ons
|
||||||
|
* - 2 tenants (Tuxedo, Indian River Direct)
|
||||||
|
* - 1 platform-admin user + 1 brand_admin per tenant
|
||||||
|
* - brand_settings per tenant
|
||||||
|
* - sample products, stops, customers per tenant
|
||||||
|
* - sample email templates + a draft campaign
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { hashPassword } from "../src/lib/passwords";
|
||||||
|
|
||||||
|
// Seed needs elevated privileges (inserts into plans, add_ons, tenants —
|
||||||
|
// tables that are intentionally not RLS-scoped but the runtime app user
|
||||||
|
// can't create rows in without setting up GUCs we don't want to bother
|
||||||
|
// with here). Use DATABASE_ADMIN_URL (the superuser) for the seed.
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString:
|
||||||
|
process.env.DATABASE_ADMIN_URL ??
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
"postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// ── Plans ────────────────────────────────────────────────────────────
|
||||||
|
const planRows = [
|
||||||
|
{ code: "starter", name: "Starter", price: 4900, maxUsers: 1, maxProducts: 25, maxStopsMonthly: 10, features: ["products", "stops", "orders", "basic_pickup"] },
|
||||||
|
{ code: "farm", name: "Farm", price: 14900, maxUsers: 5, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support"] },
|
||||||
|
{ code: "enterprise", name: "Enterprise", price: 39900, maxUsers: 9999, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support", "ai_intelligence", "sms_campaigns", "square_sync", "water_log", "custom_development", "dedicated_sla"] },
|
||||||
|
];
|
||||||
|
for (const p of planRows) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO plans (code, name, monthly_price_cents, max_users, max_products, max_stops_monthly, features)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
monthly_price_cents = EXCLUDED.monthly_price_cents,
|
||||||
|
max_users = EXCLUDED.max_users,
|
||||||
|
max_products = EXCLUDED.max_products,
|
||||||
|
max_stops_monthly = EXCLUDED.max_stops_monthly,
|
||||||
|
features = EXCLUDED.features`,
|
||||||
|
[p.code, p.name, p.price, p.maxUsers, p.maxProducts, p.maxStopsMonthly, JSON.stringify(p.features)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`Seeded ${planRows.length} plans`);
|
||||||
|
|
||||||
|
// ── Add-ons ──────────────────────────────────────────────────────────
|
||||||
|
const addOnRows = [
|
||||||
|
{ code: "wholesale_portal", name: "Wholesale Portal", price: 9900, description: "Self-serve wholesale storefront with order approval." },
|
||||||
|
{ code: "harvest_reach", name: "Harvest Reach", price: 7900, description: "Email + SMS campaigns and automations." },
|
||||||
|
{ code: "ai_tools", name: "AI Intelligence Pack", price: 5900, description: "Smart pricing, product descriptions, and demand forecasting." },
|
||||||
|
{ code: "water_log", name: "Water Log", price: 3900, description: "Irrigation tracking and reporting." },
|
||||||
|
{ code: "square_sync", name: "Square Sync", price: 3900, description: "Sync inventory and sales to Square POS." },
|
||||||
|
{ code: "sms_campaigns", name: "SMS Campaigns", price: 2900, description: "Send SMS blasts and automations." },
|
||||||
|
];
|
||||||
|
for (const a of addOnRows) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO add_ons (code, name, monthly_price_cents, description)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
monthly_price_cents = EXCLUDED.monthly_price_cents,
|
||||||
|
description = EXCLUDED.description`,
|
||||||
|
[a.code, a.name, a.price, a.description],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`Seeded ${addOnRows.length} add-ons`);
|
||||||
|
|
||||||
|
// ── Tenants + users + content ────────────────────────────────────────
|
||||||
|
const tenantsData = [
|
||||||
|
{
|
||||||
|
slug: "tuxedo",
|
||||||
|
name: "Tuxedo Citrus",
|
||||||
|
brandName: "Tuxedo Citrus Co.",
|
||||||
|
tagline: "Sun-ripened citrus, delivered.",
|
||||||
|
aboutHtml:
|
||||||
|
"<p>Family-run citrus grove in the Indian River region. We grow, pack, and ship premium grapefruit, oranges, and specialty varieties to wholesale buyers across the country.</p>",
|
||||||
|
primaryColor: "#F59E0B",
|
||||||
|
contactEmail: "hello@tuxedocitrus.example",
|
||||||
|
contactPhone: "(555) 010-2200",
|
||||||
|
planCode: "farm",
|
||||||
|
addOns: ["wholesale_portal", "harvest_reach"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "indian-river-direct",
|
||||||
|
name: "Indian River Direct",
|
||||||
|
brandName: "Indian River Direct",
|
||||||
|
tagline: "From our groves to your store.",
|
||||||
|
aboutHtml:
|
||||||
|
"<p>Direct-from-grove wholesale produce. Family farms, fair pricing, fast delivery.</p>",
|
||||||
|
primaryColor: "#0F766E",
|
||||||
|
contactEmail: "orders@indianriverdirect.example",
|
||||||
|
contactPhone: "(555) 010-3300",
|
||||||
|
planCode: "starter",
|
||||||
|
addOns: ["wholesale_portal"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const t of tenantsData) {
|
||||||
|
// Upsert tenant
|
||||||
|
const tenantRes = await client.query<{ id: string }>(
|
||||||
|
`INSERT INTO tenants (name, slug, status, trial_ends_at)
|
||||||
|
VALUES ($1, $2, 'active', now() + interval '30 days')
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING id`,
|
||||||
|
[t.name, t.slug],
|
||||||
|
);
|
||||||
|
const tenantId = tenantRes.rows[0].id;
|
||||||
|
|
||||||
|
// Plan + subscription
|
||||||
|
const planRes = await client.query<{ id: string }>(
|
||||||
|
`SELECT id FROM plans WHERE code = $1`,
|
||||||
|
[t.planCode],
|
||||||
|
);
|
||||||
|
const planId = planRes.rows[0].id;
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO subscriptions (tenant_id, plan_id, status, current_period_end)
|
||||||
|
VALUES ($1, $2, 'active', now() + interval '30 days')
|
||||||
|
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||||
|
plan_id = EXCLUDED.plan_id,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
current_period_end = EXCLUDED.current_period_end`,
|
||||||
|
[tenantId, planId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add-ons (composite PK — ON CONFLICT has a target)
|
||||||
|
for (const code of t.addOns) {
|
||||||
|
const addOnRes = await client.query<{ id: string }>(
|
||||||
|
`SELECT id FROM add_ons WHERE code = $1`,
|
||||||
|
[code],
|
||||||
|
);
|
||||||
|
if (!addOnRes.rows[0]) continue;
|
||||||
|
const addOnId = addOnRes.rows[0].id;
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO tenant_add_ons (tenant_id, add_on_id, status)
|
||||||
|
VALUES ($1, $2, 'active')
|
||||||
|
ON CONFLICT (tenant_id, add_on_id) DO NOTHING`,
|
||||||
|
[tenantId, addOnId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brand settings (PK is tenant_id)
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO brand_settings
|
||||||
|
(tenant_id, brand_name, tagline, about_html, primary_color, contact_email, contact_phone)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||||
|
brand_name = EXCLUDED.brand_name,
|
||||||
|
tagline = EXCLUDED.tagline,
|
||||||
|
about_html = EXCLUDED.about_html,
|
||||||
|
primary_color = EXCLUDED.primary_color,
|
||||||
|
contact_email = EXCLUDED.contact_email,
|
||||||
|
contact_phone = EXCLUDED.contact_phone`,
|
||||||
|
[
|
||||||
|
tenantId, t.brandName, t.tagline, t.aboutHtml,
|
||||||
|
t.primaryColor, t.contactEmail, t.contactPhone,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Brand admin user
|
||||||
|
const userRes = await client.query<{ id: string }>(
|
||||||
|
`INSERT INTO users (email, name, auth_provider, auth_subject)
|
||||||
|
VALUES ($1, $2, 'dev', $3)
|
||||||
|
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING id`,
|
||||||
|
[`admin@${t.slug}.example`, `${t.brandName} Admin`, `dev-${t.slug}-admin`],
|
||||||
|
);
|
||||||
|
const userId = userRes.rows[0].id;
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO tenant_users (tenant_id, user_id, role)
|
||||||
|
VALUES ($1, $2, 'brand_admin')
|
||||||
|
ON CONFLICT (tenant_id, user_id) DO NOTHING`,
|
||||||
|
[tenantId, userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sample products — use WHERE NOT EXISTS (no good unique target other than id)
|
||||||
|
const products = [
|
||||||
|
{ name: "Ruby Red Grapefruit", price: 2400, unit: "40 lb case", desc: "Sweet, juicy, seedless." },
|
||||||
|
{ name: "Navel Oranges", price: 2200, unit: "40 lb case", desc: "Classic eating orange, easy-peel." },
|
||||||
|
{ name: "Honeybells", price: 3200, unit: "20 lb case", desc: "Limited season — bell-shaped, super sweet." },
|
||||||
|
{ name: "Tangelos", price: 2800, unit: "40 lb case", desc: "Tangerine-grapefruit hybrid." },
|
||||||
|
];
|
||||||
|
for (const p of products) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO products (tenant_id, name, description, price_cents, inventory, unit, active)
|
||||||
|
SELECT $1, $2, $3, $4, 100, $5, true
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM products WHERE tenant_id = $1 AND name = $2
|
||||||
|
)`,
|
||||||
|
[tenantId, p.name, p.desc, p.price, p.unit],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample stops
|
||||||
|
const stops = [
|
||||||
|
{ name: "Downtown Farmers Market", address: "100 Main St", schedule: [{ day: "Saturday", time: "08:00" }] },
|
||||||
|
{ name: "Eastside Pickup Hub", address: "555 Oak Ave", schedule: [{ day: "Wednesday", time: "16:00" }] },
|
||||||
|
];
|
||||||
|
for (const s of stops) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO stops (tenant_id, name, address, schedule, status)
|
||||||
|
SELECT $1, $2, $3, $4::jsonb, 'active'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM stops WHERE tenant_id = $1 AND name = $2
|
||||||
|
)`,
|
||||||
|
[tenantId, s.name, s.address, JSON.stringify(s.schedule)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample customers (email has a unique index per tenant)
|
||||||
|
const customers = [
|
||||||
|
{ name: "Green Grocer Co.", email: "buyer@greengrocer.example", phone: "555-010-0010" },
|
||||||
|
{ name: "Sunset Cafe", email: "orders@sunsetcafe.example", phone: "555-010-0020" },
|
||||||
|
{ name: "Northside Co-op", email: "produce@northsidecoop.example", phone: "555-010-0030" },
|
||||||
|
];
|
||||||
|
for (const c of customers) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO customers (tenant_id, name, email, phone, sms_opt_in, email_opt_in)
|
||||||
|
SELECT $1, $2, $3, $4, true, true
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM customers WHERE tenant_id = $1 AND email = $3
|
||||||
|
)`,
|
||||||
|
[tenantId, c.name, c.email, c.phone],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample email template (id-based, use WHERE NOT EXISTS)
|
||||||
|
const tmplRes = await client.query<{ id: string }>(
|
||||||
|
`INSERT INTO email_templates (tenant_id, name, subject, body_html)
|
||||||
|
SELECT $1, 'Weekly Availability', 'This week at the grove', '<h1>Fresh this week</h1><p>Hi {{name}}, our harvest is in. Reply to reserve.</p>'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM email_templates WHERE tenant_id = $1 AND name = 'Weekly Availability'
|
||||||
|
)
|
||||||
|
RETURNING id`,
|
||||||
|
[tenantId],
|
||||||
|
);
|
||||||
|
if (tmplRes.rows[0]) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO campaigns (tenant_id, template_id, name, status)
|
||||||
|
SELECT $1, $2, 'Welcome series — week 1', 'draft'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM campaigns WHERE tenant_id = $1 AND name = 'Welcome series — week 1'
|
||||||
|
)`,
|
||||||
|
[tenantId, tmplRes.rows[0].id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Seeded ${tenantsData.length} tenants with sample data`);
|
||||||
|
|
||||||
|
// ── Platform admin (seeded for dev sign-in via Credentials provider) ─
|
||||||
|
// email: admin@route-commerce.local
|
||||||
|
// password: admin (override with SEED_ADMIN_PASSWORD)
|
||||||
|
//
|
||||||
|
// The user is attached to the Tuxedo tenant with the `platform_admin`
|
||||||
|
// role so `getAdminUser()` resolves it and grants cross-tenant
|
||||||
|
// visibility. To rotate the password, re-run `npm run db:seed`
|
||||||
|
// (the UPSERT updates `password_hash`).
|
||||||
|
const tuxedoRes = await client.query<{ id: string }>(
|
||||||
|
`SELECT id FROM tenants WHERE slug = 'tuxedo'`,
|
||||||
|
);
|
||||||
|
const tuxedoId = tuxedoRes.rows[0]?.id;
|
||||||
|
if (tuxedoId) {
|
||||||
|
const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? "admin";
|
||||||
|
const passwordHash = hashPassword(adminPassword);
|
||||||
|
const adminRes = await client.query<{ id: string }>(
|
||||||
|
`INSERT INTO users (email, name, auth_provider, auth_subject, password_hash)
|
||||||
|
VALUES ($1, 'Platform Admin', 'dev', 'dev-platform-admin', $2)
|
||||||
|
ON CONFLICT (email) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
password_hash = EXCLUDED.password_hash
|
||||||
|
RETURNING id`,
|
||||||
|
["admin@route-commerce.local", passwordHash],
|
||||||
|
);
|
||||||
|
const adminId = adminRes.rows[0].id;
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO tenant_users (tenant_id, user_id, role)
|
||||||
|
VALUES ($1, $2, 'platform_admin')
|
||||||
|
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||||
|
[tuxedoId, adminId],
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Seeded platform admin: admin@route-commerce.local / ${adminPassword === "admin" ? "admin" : "(SEED_ADMIN_PASSWORD)"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
console.log("✅ Seed complete");
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => pool.end())
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("❌ Seed failed:", err);
|
||||||
|
pool.end();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { chromium } from 'playwright';
|
|
||||||
|
|
||||||
async function debugAuth() {
|
|
||||||
console.log('Launching browser...');
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const context = await browser.newContext();
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
// First, login
|
|
||||||
console.log('Navigating to login page...');
|
|
||||||
await page.goto('https://route-commerce-platform.vercel.app/login');
|
|
||||||
|
|
||||||
console.log('Filling login form...');
|
|
||||||
await page.fill('#email', 'kylemart@gmail.com');
|
|
||||||
await page.fill('#password', 'Test123456!');
|
|
||||||
|
|
||||||
console.log('Clicking sign in...');
|
|
||||||
const response = await page.click('button[type="submit"]');
|
|
||||||
|
|
||||||
// Wait for network to settle
|
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
|
||||||
|
|
||||||
console.log('Current URL after wait:', page.url());
|
|
||||||
|
|
||||||
// Get any error messages
|
|
||||||
const errorText = await page.$eval('[role="alert"]', el => el.textContent).catch(() => null);
|
|
||||||
if (errorText) console.log('Error message:', errorText);
|
|
||||||
|
|
||||||
const pageContent = await page.content();
|
|
||||||
if (pageContent.includes('Access Denied')) {
|
|
||||||
console.log('*** ACCESS DENIED PAGE DETECTED ***');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cookies
|
|
||||||
const cookies = await context.cookies();
|
|
||||||
console.log('Cookies:', cookies.map(c => `${c.name}=${c.value.slice(0, 30)}...`));
|
|
||||||
|
|
||||||
// Try to visit debug-auth page
|
|
||||||
console.log('Navigating to /admin/debug-auth...');
|
|
||||||
try {
|
|
||||||
const response = await page.goto('https://route-commerce-platform.vercel.app/admin/debug-auth', { timeout: 10000 });
|
|
||||||
console.log('Response status:', response?.status());
|
|
||||||
console.log('Response URL:', page.url());
|
|
||||||
const content = await page.content();
|
|
||||||
console.log('Page content (first 2000 chars):', content.slice(0, 2000));
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Error:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
debugAuth().catch(console.error);
|
|
||||||
@@ -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>"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Runtime artefacts written by deploy.sh — do NOT commit these.
|
||||||
|
.deploy.lock
|
||||||
|
deploy.log
|
||||||
|
.postgrest-port
|
||||||
|
.nextjs-port
|
||||||
|
.env.production
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
Executable
+429
@@ -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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# docker-compose.yml — production stack consumed by deploy.sh
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Only `postgrest` lives in docker. Postgres itself runs on the host
|
||||||
|
# (the deploy workflow applies migrations via `psql -h 127.0.0.1`).
|
||||||
|
# Next.js runs under PM2 on the host — it is NOT a docker service.
|
||||||
|
#
|
||||||
|
# The host-side port (POSTGREST_HOST_PORT) is written by the deploy
|
||||||
|
# workflow into $APP_DIR/.env. We interpolate from there with
|
||||||
|
# ${VAR:-3011} so a manual `docker compose up` without the deploy
|
||||||
|
# script still works.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
Executable
+54
@@ -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"
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
# Multi-Brand Admin Support
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Draft → Approved
|
||||||
|
**Author:** Grok (brainstorming session)
|
||||||
|
**Migration file:** `supabase/migrations/204_multi_brand_admin.sql`
|
||||||
|
**Follow-up migration (out of scope):** `220_drop_legacy_brand_id.sql`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`admin_users.brand_id` is a single `UUID | null`. The platform supports a `platform_admin` role (no brand) and a `brand_admin` role (one brand). There is no representation for an admin who legitimately needs access to 2+ specific brands — e.g., a parent company operating multiple storefronts with shared operations staff.
|
||||||
|
|
||||||
|
The current `effectiveBrandId = brandId ?? adminUser.brand_id ?? null` pattern silently does the wrong thing for multi-brand use cases:
|
||||||
|
|
||||||
|
- Data model can't represent "Jane is admin for Brand A AND Brand B" — would require two `admin_users` rows (and the auth-user→admin-user join gets messy).
|
||||||
|
- No central validation that an admin is acting in a brand they actually have access to.
|
||||||
|
- No persistent "current brand" context — every page re-derives it from scratch.
|
||||||
|
- No per-brand permission overrides possible (locks in flexibility for the future).
|
||||||
|
|
||||||
|
This spec addresses multi-brand tenants / franchises: same staff managing multiple brands under a parent org, where brands are separate for storefront/billing but share operations.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- An admin can be associated with multiple brands via a junction table.
|
||||||
|
- A persistent "active brand" is stored in a cookie, switchable via UI, and used as the default when no explicit brand is requested.
|
||||||
|
- A new `multi_brand_admin` role makes the relationship explicit in the data and the UI.
|
||||||
|
- `platform_admin` continues to work unchanged (gets all brands implicitly).
|
||||||
|
- Existing single-brand `brand_admin`, `store_employee`, and `staff` users continue to work with zero behavior change.
|
||||||
|
- Server actions get a single, central place to resolve and validate the active brand.
|
||||||
|
|
||||||
|
## Non-Goals (YAGNI)
|
||||||
|
|
||||||
|
- Per-(admin, brand) permission overrides. The user explicitly chose "same perms across all brands."
|
||||||
|
- Brand-group / parent-org concept. The junction table makes this possible later, but it's not built now.
|
||||||
|
- "Last accessed brand" auto-redirect. The cookie is the source of truth; no extra logic.
|
||||||
|
- UI for managing `admin_user_brands` rows. Use Supabase Studio or a follow-up admin UI PR.
|
||||||
|
- Dropping the legacy `admin_users.brand_id` column. A follow-up migration `220_drop_legacy_brand_id.sql` will do this after we verify nothing reads it. Out of scope for this spec.
|
||||||
|
|
||||||
|
## Approach: Junction Table + Backwards-Compat `brand_id`
|
||||||
|
|
||||||
|
Selected from among three options:
|
||||||
|
|
||||||
|
| Option | Why not |
|
||||||
|
|---|---|
|
||||||
|
| A. Junction + keep `brand_id` (selected) | — |
|
||||||
|
| B. Junction only, drop `brand_id` | High migration risk; every server action reference must change. |
|
||||||
|
| C. `brand_ids UUID[]` on `admin_users` | No FK, awkward reverse lookups, locks out per-brand metadata. |
|
||||||
|
|
||||||
|
A is the lowest-risk additive path that solves the problem.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### New table: `admin_user_brands`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE admin_user_brands (
|
||||||
|
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
added_by UUID REFERENCES admin_users(id),
|
||||||
|
PRIMARY KEY (admin_user_id, brand_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
- Composite PK enforces uniqueness.
|
||||||
|
- Index on `brand_id` makes "which admins are in Brand X?" queries fast.
|
||||||
|
- `added_at` / `added_by` for audit trail.
|
||||||
|
- `ON DELETE CASCADE` for both FKs — deleting an admin or brand cleans up the junction.
|
||||||
|
|
||||||
|
### New role: `multi_brand_admin`
|
||||||
|
|
||||||
|
Added to the `role` CHECK constraint. Functionally equivalent to `brand_admin` permission-wise — same `can_manage_*` flags apply. The role label disambiguates intent in the UI ("this person manages 3 brands" vs "this person manages 1 brand") and in audit logs.
|
||||||
|
|
||||||
|
### Updated `AdminUser` type
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/admin-permissions-types.ts
|
||||||
|
export type AdminUser = {
|
||||||
|
// ... existing fields
|
||||||
|
brand_id: string | null; // active brand (one of brand_ids, or null for platform_admin)
|
||||||
|
brand_ids: string[]; // all brands this admin can act in
|
||||||
|
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Membership rules
|
||||||
|
|
||||||
|
| Role | `brand_id` (active) | `brand_ids` (membership) |
|
||||||
|
|---|---|---|
|
||||||
|
| `platform_admin` | `null` (or cookie-selected brand) | Implicitly all brands; `brand_ids` populated by `listBrandsForAdmin` querying the `brands` table |
|
||||||
|
| `multi_brand_admin` | First/selected brand | 2+ brands from `admin_user_brands` |
|
||||||
|
| `brand_admin` | Their single brand | `[that one brand]` |
|
||||||
|
| `store_employee` | Their single brand | `[that one brand]` |
|
||||||
|
| `staff` | Their single brand | `[that one brand]` |
|
||||||
|
|
||||||
|
For `platform_admin`, the application layer short-circuits brand-access checks (`if (adminUser.role === "platform_admin") return ...`). The `brand_ids` field is only used by `listBrandsForAdmin` to render the dropdown options; it is not used for permission gating for `platform_admin`.
|
||||||
|
|
||||||
|
### `getAdminUser()` resolution order
|
||||||
|
|
||||||
|
1. If `dev_session` cookie set → return dev admin. For `platform_admin` dev: `brand_id: null, brand_ids: []` (resolved against `brands` table by `listBrandsForAdmin`). For `store_employee` dev: `brand_id: <first-real-brand-id>, brand_ids: [<that-id>]` — fetched from the `brands` table so dev store_employee can browse a real brand's data. (If no brands exist, dev store_employee sees `<AdminAccessDenied />` — known limitation.)
|
||||||
|
2. If `NEXT_PUBLIC_USE_MOCK_DATA=true` → same as `platform_admin` dev.
|
||||||
|
3. Real auth → load `admin_users` row, then JOIN `admin_user_brands` to populate `brand_ids`.
|
||||||
|
4. Set `brand_id` from (in order): `active_brand_id` cookie (if in `brand_ids` for non-platform-admin, or always for platform-admin) → `admin_users.brand_id` (if in `brand_ids`) → first of `brand_ids`.
|
||||||
|
|
||||||
|
## Server Action Patterns
|
||||||
|
|
||||||
|
### New file: `src/lib/brand-scope.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import type { AdminUser } from "./admin-permissions-types";
|
||||||
|
|
||||||
|
const ACTIVE_BRAND_COOKIE = "active_brand_id";
|
||||||
|
|
||||||
|
export async function getActiveBrandId(
|
||||||
|
adminUser: AdminUser,
|
||||||
|
requested?: string | null
|
||||||
|
): Promise<string | null> {
|
||||||
|
// Cookie is the source of truth for "what brand am I acting in right now"
|
||||||
|
// for everyone — including platform_admin (who can pin a specific brand
|
||||||
|
// or fall back to null = "all brands").
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const cookieBrand = cookieStore.get(ACTIVE_BRAND_COOKIE)?.value ?? null;
|
||||||
|
|
||||||
|
if (adminUser.role === "platform_admin") {
|
||||||
|
// requested > cookie > null (all brands)
|
||||||
|
return requested ?? cookieBrand ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-platform-admin: requested (if in brand_ids) > cookie (if in brand_ids) > adminUser.brand_id
|
||||||
|
if (requested) {
|
||||||
|
return adminUser.brand_ids.includes(requested) ? requested : null;
|
||||||
|
}
|
||||||
|
if (cookieBrand && adminUser.brand_ids.includes(cookieBrand)) {
|
||||||
|
return cookieBrand;
|
||||||
|
}
|
||||||
|
return adminUser.brand_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setActiveBrandCookie(brandId: string): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(ACTIVE_BRAND_COOKIE, brandId, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearActiveBrandCookie(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete(ACTIVE_BRAND_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertBrandAccess(adminUser: AdminUser, brandId: string): void {
|
||||||
|
if (adminUser.role === "platform_admin") return;
|
||||||
|
if (!adminUser.brand_ids.includes(brandId)) {
|
||||||
|
throw new Error("Brand access denied");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server action: set active brand
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/actions/admin/set-active-brand.ts
|
||||||
|
"use server";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { setActiveBrandCookie, clearActiveBrandCookie } from "@/lib/brand-scope";
|
||||||
|
|
||||||
|
export async function setActiveBrand(brandId: string | null): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
|
// null = "All brands" (platform_admin only)
|
||||||
|
if (brandId === null) {
|
||||||
|
if (adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Only platform admins can select 'All brands'" };
|
||||||
|
}
|
||||||
|
await clearActiveBrandCookie();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser.role !== "platform_admin" && !adminUser.brand_ids.includes(brandId)) {
|
||||||
|
return { success: false, error: "No access to that brand" };
|
||||||
|
}
|
||||||
|
await setActiveBrandCookie(brandId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New server function: list brands for admin
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/actions/brands.ts
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
|
export async function listBrandsForAdmin(): Promise<
|
||||||
|
{ id: string; name: string; slug: string; logo_url: string | null }[]
|
||||||
|
> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
|
||||||
|
if (adminUser.role === "platform_admin") {
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
|
||||||
|
{ headers: svcHeaders(serviceKey) }
|
||||||
|
);
|
||||||
|
return res.ok ? await res.json() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser.brand_ids.length === 0) return [];
|
||||||
|
|
||||||
|
const filter = `id=in.(${adminUser.brand_ids.map((id) => `"${id}"`).join(",")})`;
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
|
||||||
|
{ headers: svcHeaders(serviceKey) }
|
||||||
|
);
|
||||||
|
return res.ok ? await res.json() : [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server action migration pattern
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Before:
|
||||||
|
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||||
|
|
||||||
|
// After:
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a mechanical one-line swap in ~30 server actions identified by the grep:
|
||||||
|
|
||||||
|
- `src/actions/wholesale.ts` (multiple)
|
||||||
|
- `src/actions/products.ts`
|
||||||
|
- `src/actions/communications/templates.ts`
|
||||||
|
- `src/actions/communications/campaigns.ts`
|
||||||
|
- `src/actions/orders/create-admin-order.ts`
|
||||||
|
- `src/actions/stops.ts`
|
||||||
|
- `src/actions/analytics.ts`
|
||||||
|
- `src/actions/square-sync-ui.ts`
|
||||||
|
- `src/actions/shipping.ts`
|
||||||
|
- `src/actions/payments.ts`
|
||||||
|
- `src/actions/ai-import.ts`
|
||||||
|
- `src/actions/wholesale-register.ts`
|
||||||
|
|
||||||
|
### Page server component pattern
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Before (src/app/admin/orders/page.tsx):
|
||||||
|
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
|
||||||
|
|
||||||
|
// After:
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
|
||||||
|
export default async function AdminOrdersPage() {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return <AdminAccessDenied />;
|
||||||
|
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
||||||
|
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return <AdminAccessDenied />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... rest of the page
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to every page under `src/app/admin/` — including the existing `/admin/taxes/[brandId]` and `/admin/settings/billing/[brandId]` which already do brand param resolution.
|
||||||
|
|
||||||
|
## UI Components
|
||||||
|
|
||||||
|
### Brand selector
|
||||||
|
|
||||||
|
**Location:** `src/components/admin/AdminHeader.tsx` (the top bar already rendered on every `/admin/*` page).
|
||||||
|
|
||||||
|
**Behavior:** A dropdown showing:
|
||||||
|
- Brand logo + name (current active brand)
|
||||||
|
- Chevron
|
||||||
|
- "All brands" option at the top (only for `platform_admin`)
|
||||||
|
- List of accessible brands (`adminUser.brand_ids`)
|
||||||
|
- Small badge "Multi-brand manager" next to the user's name when `role === "multi_brand_admin"`
|
||||||
|
|
||||||
|
**Visibility matrix:**
|
||||||
|
|
||||||
|
| Admin | Show dropdown? | Options |
|
||||||
|
|---|---|---|
|
||||||
|
| `platform_admin` | Yes | "All brands" + list of all brands |
|
||||||
|
| `multi_brand_admin` (2+ brands) | Yes | List of their brands |
|
||||||
|
| `brand_admin` / `store_employee` / `staff` (1 brand) | No | — |
|
||||||
|
| `platform_admin` (dev_session) | Yes | "All brands" + list of all brands (same UX as production) |
|
||||||
|
|
||||||
|
**On select:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/components/admin/BrandSelector.tsx (client component)
|
||||||
|
"use client";
|
||||||
|
async function handleSelect(brandId: string | null) {
|
||||||
|
await setActiveBrand(brandId); // null = "All brands"
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`router.refresh()` re-runs server components and re-reads the cookie, so all data on the current page reloads in the new brand context. The URL is **not** changed — the cookie is the source of truth for "what brand am I acting in right now."
|
||||||
|
|
||||||
|
### URL-level brand params
|
||||||
|
|
||||||
|
Keep URL-level brand as-is — URLs are shareable links. The resolution order for the `brandId` param passed to `getActiveBrandId` is:
|
||||||
|
|
||||||
|
1. URL `brandId` param (if present)
|
||||||
|
2. `active_brand_id` cookie
|
||||||
|
3. `adminUser.brand_id` (legacy single brand)
|
||||||
|
4. First of `adminUser.brand_ids`
|
||||||
|
5. (platform_admin only) `null` → "all brands"
|
||||||
|
|
||||||
|
## Migration: `supabase/migrations/204_multi_brand_admin.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. Add multi_brand_admin to role CHECK constraint
|
||||||
|
ALTER TABLE admin_users DROP CONSTRAINT IF EXISTS admin_users_role_check;
|
||||||
|
ALTER TABLE admin_users ADD CONSTRAINT admin_users_role_check
|
||||||
|
CHECK (role IN ('platform_admin', 'brand_admin', 'multi_brand_admin', 'store_employee', 'staff'));
|
||||||
|
|
||||||
|
-- 2. Create junction table
|
||||||
|
CREATE TABLE admin_user_brands (
|
||||||
|
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
added_by UUID REFERENCES admin_users(id),
|
||||||
|
PRIMARY KEY (admin_user_id, brand_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
|
||||||
|
|
||||||
|
-- 3. Backfill from existing brand_id (single-brand admins)
|
||||||
|
INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
||||||
|
SELECT id, brand_id FROM admin_users
|
||||||
|
WHERE brand_id IS NOT NULL
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- 4. Promote anyone with > 1 brand to multi_brand_admin
|
||||||
|
UPDATE admin_users
|
||||||
|
SET role = 'multi_brand_admin'
|
||||||
|
WHERE role = 'brand_admin'
|
||||||
|
AND id IN (
|
||||||
|
SELECT admin_user_id FROM admin_user_brands
|
||||||
|
GROUP BY admin_user_id HAVING COUNT(*) > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. New RPCs for adding/removing brand access
|
||||||
|
CREATE OR REPLACE FUNCTION add_admin_user_brand(
|
||||||
|
p_admin_user_id UUID, p_brand_id UUID, p_added_by UUID
|
||||||
|
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
|
||||||
|
INSERT INTO admin_user_brands (admin_user_id, brand_id, added_by)
|
||||||
|
VALUES (p_admin_user_id, p_brand_id, p_added_by)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
UPDATE admin_users SET role = 'multi_brand_admin'
|
||||||
|
WHERE id = p_admin_user_id
|
||||||
|
AND role = 'brand_admin'
|
||||||
|
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) > 1;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION remove_admin_user_brand(
|
||||||
|
p_admin_user_id UUID, p_brand_id UUID
|
||||||
|
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
|
||||||
|
DELETE FROM admin_user_brands
|
||||||
|
WHERE admin_user_id = p_admin_user_id AND brand_id = p_brand_id;
|
||||||
|
UPDATE admin_users SET role = 'brand_admin'
|
||||||
|
WHERE id = p_admin_user_id
|
||||||
|
AND role = 'multi_brand_admin'
|
||||||
|
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) = 1;
|
||||||
|
$$;
|
||||||
|
```
|
||||||
|
|
||||||
|
The `admin_users.brand_id` column is **kept** for backwards compat. A follow-up migration drops it after we verify nothing reads it.
|
||||||
|
|
||||||
|
## Error Handling & Security Boundaries
|
||||||
|
|
||||||
|
### Three failure modes
|
||||||
|
|
||||||
|
1. **Requested brand not in `brand_ids`** (e.g., URL param has a brand the admin doesn't have):
|
||||||
|
- `getActiveBrandId()` returns `null`
|
||||||
|
- Server action returns `{ success: false, error: "Brand access required" }`
|
||||||
|
- Page renders `<AdminAccessDenied />` with: "You don't have access to that brand. [Switch to a brand you have access to]"
|
||||||
|
|
||||||
|
2. **Cookie brand no longer in `brand_ids`** (admin's access was revoked while cookie was still set):
|
||||||
|
- `getActiveBrandId()` falls through to `adminUser.brand_id`, then first of `brand_ids`
|
||||||
|
- Silent recovery — no error, no UI flash
|
||||||
|
- The dropped brand just disappears from the dropdown next page load
|
||||||
|
|
||||||
|
3. **Platform admin acting on a brand they don't own:**
|
||||||
|
- Platform admin: `brand_ids = ["*"]` (sentinel) — RPCs treat as "all brands"
|
||||||
|
- Server action: never blocks platform admin from any brand
|
||||||
|
- This is intentional — platform admin = superuser
|
||||||
|
|
||||||
|
### Validation placement (defense in depth)
|
||||||
|
|
||||||
|
- Server action: `getActiveBrandId(adminUser, requested)` validates.
|
||||||
|
- Server action: `assertBrandAccess(adminUser, brandIdFromUrl)` validates (separate, for cases where the brandId comes from URL/form/RPC return rather than `getActiveBrandId`).
|
||||||
|
- RPC: still trusts `p_brand_id` (SECURITY DEFINER) — application layer is the gate, matching the existing architecture.
|
||||||
|
|
||||||
|
### Audit logging (additive)
|
||||||
|
|
||||||
|
- `admin_user_brands.added_by` column tracks who added an admin to a brand.
|
||||||
|
- Audit log entry on add/remove: out of scope for v1; documented for follow-up.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit tests (`vitest` — new dev dependency)
|
||||||
|
|
||||||
|
- `src/lib/brand-scope.test.ts`:
|
||||||
|
- `resolveActiveBrandId(platformAdmin, "X")` → `"X"`
|
||||||
|
- `resolveActiveBrandId(brandAdmin, "X")` where X is their brand → `"X"`
|
||||||
|
- `resolveActiveBrandId(brandAdmin, "Y")` where Y is not their brand → `null`
|
||||||
|
- `getActiveBrandId(multiBrandAdmin)` with cookie set to valid brand → that brand
|
||||||
|
- `getActiveBrandId(multiBrandAdmin)` with cookie set to revoked brand → first of `brand_ids`
|
||||||
|
- `assertBrandAccess(...)` throws for non-platform-admin with invalid brand
|
||||||
|
- `setActiveBrand(null)` rejected for non-platform-admin
|
||||||
|
- `setActiveBrand("X")` rejected for admin without X in `brand_ids`
|
||||||
|
|
||||||
|
### Integration tests (Playwright — already in repo)
|
||||||
|
|
||||||
|
- `tests/admin/multi-brand.spec.ts`:
|
||||||
|
- As `multi_brand_admin`, dropdown shows 2+ brands
|
||||||
|
- Click brand B → URL stays, cookie updates, page data refreshes to brand B
|
||||||
|
- Direct-navigate to `/admin/orders?brand=<other-brand>` for a brand admin returns access denied
|
||||||
|
- As `platform_admin`, "All brands" option is present and works
|
||||||
|
- As `brand_admin` with 1 brand, no dropdown is shown
|
||||||
|
|
||||||
|
### Migration smoke test (manual, documented in MEMORY.md)
|
||||||
|
|
||||||
|
- Before migration: 5 brand_admins exist, each with 1 brand
|
||||||
|
- After migration: 5 rows in `admin_user_brands`, all `role = 'brand_admin'`
|
||||||
|
- Create a 6th admin with 2 brands via `add_admin_user_brand` → `role = 'multi_brand_admin'`, 2 rows in junction
|
||||||
|
- Remove one of their brands via `remove_admin_user_brand` → `role` demotes to `brand_admin`, 1 row in junction
|
||||||
|
|
||||||
|
## Out of Scope (v1)
|
||||||
|
|
||||||
|
- Per-(admin, brand) permission overrides
|
||||||
|
- Brand-group / parent-org concept
|
||||||
|
- "Last accessed brand" auto-redirect
|
||||||
|
- UI for managing `admin_user_brands` rows (Supabase Studio works for now)
|
||||||
|
- Dropping the legacy `admin_users.brand_id` column (follow-up `220_*` migration)
|
||||||
|
- Audit log entries for add/remove (junction's `added_by` column is the seed)
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Migration `204_multi_brand_admin.sql` applied
|
||||||
|
2. `src/lib/brand-scope.ts` + unit tests (TDD)
|
||||||
|
3. `AdminUser` type updated; `getAdminUser()` returns `brand_ids`
|
||||||
|
4. `setActiveBrand` server action + tests
|
||||||
|
5. `listBrandsForAdmin` server function + tests
|
||||||
|
6. BrandSelector UI component
|
||||||
|
7. Wire BrandSelector into AdminHeader
|
||||||
|
8. Server action migration (~30 actions) — mechanical one-line swap each
|
||||||
|
9. Page server component migration (~10+ pages) — same pattern
|
||||||
|
10. Playwright integration tests
|
||||||
|
11. Manual smoke test of the migration on dev DB
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Drizzle Kit config. Used by `drizzle-kit generate` / `drizzle-kit push`
|
||||||
|
* for future migrations. The schema in `db/migrations/0001_init.sql` is
|
||||||
|
* the source of truth for v1; subsequent migrations can be generated
|
||||||
|
* from changes to `db/schema/*.ts` and committed alongside the SQL.
|
||||||
|
*/
|
||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./db/schema/index.ts",
|
||||||
|
out: "./db/migrations",
|
||||||
|
dialect: "postgresql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL ?? "postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
verbose: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import openpyxl
|
||||||
|
path = "/home/coder/dev/x1/kyle/route_commerce-main/Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
|
||||||
|
wb = openpyxl.load_workbook(path, data_only=True)
|
||||||
|
for name in wb.sheetnames:
|
||||||
|
ws = wb[name]
|
||||||
|
print(f"=== SHEET: {name} ({ws.max_row} rows x {ws.max_column} cols) ===")
|
||||||
|
for row in ws.iter_rows(values_only=False):
|
||||||
|
for cell in row:
|
||||||
|
if cell.value is not None:
|
||||||
|
v = str(cell.value)
|
||||||
|
if len(v) > 200:
|
||||||
|
v = v[:200] + "..."
|
||||||
|
print(f" {cell.coordinate}: {v!r}")
|
||||||
|
print()
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
|
||||||
|
|
||||||
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";
|
|
||||||
const rcAuthUid = request.cookies.get("rc_auth_uid")?.value;
|
|
||||||
|
|
||||||
let authUid: string | null = null;
|
|
||||||
|
|
||||||
if (isDevMode) {
|
|
||||||
// Dev session only valid in development
|
|
||||||
authUid = DEV_UID;
|
|
||||||
} else if (rcAuthUid) {
|
|
||||||
// rc_auth_uid is set by /api/login — treat as authenticated
|
|
||||||
authUid = rcAuthUid;
|
|
||||||
}
|
|
||||||
// No rc_auth_uid in production → authUid stays null → redirect to /login
|
|
||||||
|
|
||||||
const isAdmin = request.nextUrl.pathname.startsWith("/admin");
|
|
||||||
const isLogin = request.nextUrl.pathname === "/login";
|
|
||||||
|
|
||||||
if (isAdmin && !authUid) {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
// Auto-login for demo: no Supabase configured, no auth cookie present
|
|
||||||
if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) {
|
|
||||||
const url = request.nextUrl.clone();
|
|
||||||
url.pathname = "/admin";
|
|
||||||
url.searchParams.set("demo", "1");
|
|
||||||
const response = NextResponse.redirect(url);
|
|
||||||
response.cookies.set("dev_session", "platform_admin", {
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "strict",
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
const url = request.nextUrl.clone();
|
|
||||||
url.pathname = "/login";
|
|
||||||
return NextResponse.redirect(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLogin && authUid) {
|
|
||||||
const url = request.nextUrl.clone();
|
|
||||||
url.pathname = "/admin";
|
|
||||||
return NextResponse.redirect(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
"/admin/:path*",
|
|
||||||
"/admin",
|
|
||||||
"/login",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
// Lock the file-tracing root to the project directory. Without this,
|
||||||
|
// Next.js 16 walks up from package.json looking for a lockfile, finds
|
||||||
|
// the homelab runner's stale `act` cache at
|
||||||
|
// /home/tyler/.cache/act/.../package-lock.json, and warns:
|
||||||
|
// "We detected multiple lockfiles and selected the directory of
|
||||||
|
// /home/tyler/package-lock.json as the root directory."
|
||||||
|
// The deploy runner's APP_DIR is /home/tyler/route-commerce, so
|
||||||
|
// resolving relative to the project root is correct both locally and
|
||||||
|
// in CI.
|
||||||
|
outputFileTracingRoot: ".",
|
||||||
|
|
||||||
// Enable strict mode
|
// Enable strict mode
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
|
|||||||
+21
-4
@@ -8,14 +8,22 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"lint:fix": "eslint --fix",
|
"lint:fix": "eslint --fix",
|
||||||
"migrate": "node supabase/push-migrations.js",
|
"migrate": "node scripts/migrate.js",
|
||||||
"migrate:one": "node supabase/push-migrations.js",
|
"migrate:one": "node scripts/migrate.js",
|
||||||
|
"db:migrate": "node scripts/migrate.js",
|
||||||
|
"db:seed": "tsx db/seed.ts",
|
||||||
|
"db:reset": "node scripts/db-reset.js",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
"type-check": "npx tsc --noEmit",
|
"type-check": "npx tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:e2e": "playwright test --project=local",
|
||||||
|
"test:e2e:prod": "PLAYWRIGHT_PROD=1 playwright test",
|
||||||
"format": "prettier --write \"src/**/*.{ts,tsx}\""
|
"format": "prettier --write \"src/**/*.{ts,tsx}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.96.0",
|
"@anthropic-ai/sdk": "^0.96.0",
|
||||||
"@clerk/nextjs": "^7.4.2",
|
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
"@sentry/nextjs": "^10.55.0",
|
"@sentry/nextjs": "^10.55.0",
|
||||||
@@ -23,11 +31,13 @@
|
|||||||
"@supabase/supabase-js": "^2.105.3",
|
"@supabase/supabase-js": "^2.105.3",
|
||||||
"@upstash/ratelimit": "^2.0.8",
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
"@upstash/redis": "^1.38.0",
|
"@upstash/redis": "^1.38.0",
|
||||||
|
"drizzle-orm": "^0.36.4",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"openai": "^6.37.0",
|
"openai": "^6.37.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
@@ -48,17 +58,24 @@
|
|||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/uuid": "^11.0.0",
|
"@types/uuid": "^11.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
"drizzle-kit": "^0.30.6",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.5",
|
"eslint-config-next": "16.2.5",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"tsx": "^4.22.4",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
},
|
},
|
||||||
"overrides": "{}"
|
"overrides": "{}"
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-5
@@ -1,6 +1,9 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
const LOCAL_BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
|
||||||
|
const PROD_BASE = "https://route-commerce-platform.vercel.app";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
@@ -9,16 +12,19 @@ export default defineConfig({
|
|||||||
workers: 1,
|
workers: 1,
|
||||||
reporter: "list",
|
reporter: "list",
|
||||||
use: {
|
use: {
|
||||||
baseURL: "https://route-commerce-platform.vercel.app",
|
baseURL: LOCAL_BASE,
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
|
{
|
||||||
|
name: "local",
|
||||||
|
use: { ...devices["Desktop Chrome"], baseURL: LOCAL_BASE },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "production",
|
name: "production",
|
||||||
use: {
|
// `PLAYWRIGHT_PROD=1 npx playwright test` to run against the live site.
|
||||||
...devices["Desktop Chrome"],
|
testMatch: /.*\.prod\.spec\.ts$/,
|
||||||
baseURL: "https://route-commerce-platform.vercel.app",
|
use: { ...devices["Desktop Chrome"], baseURL: PROD_BASE },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* DESTRUCTIVE: drops and recreates the `route_commerce` database, then
|
||||||
|
* applies all migrations and seeds.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm run db:reset
|
||||||
|
*
|
||||||
|
* Use only in dev. Requires sudo-less access to a postgres superuser.
|
||||||
|
*/
|
||||||
|
require("dotenv").config({ path: ".env.local" });
|
||||||
|
const { execSync } = require("node:child_process");
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
console.error("❌ DATABASE_URL is not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = url.match(/postgres(?:ql)?:\/\/([^:]+):[^@]+@([^:]+):(\d+)\/(.+)/);
|
||||||
|
if (!m) {
|
||||||
|
console.error("❌ Could not parse DATABASE_URL");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const [, user, host, port, db] = m;
|
||||||
|
const adminUrl = url.replace(/\/[^/]+$/, "/postgres");
|
||||||
|
|
||||||
|
console.log(`⚠️ DROPPING and recreating ${db} on ${host}:${port} as ${user}`);
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`psql "${adminUrl}" -c "DROP DATABASE IF EXISTS ${db};" -c "CREATE DATABASE ${db} OWNER ${user};"`,
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
console.log("✓ Database recreated");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Failed to drop/create database. Try with sudo:");
|
||||||
|
console.error(
|
||||||
|
` sudo -u postgres psql -c "DROP DATABASE IF EXISTS ${db};" -c "CREATE DATABASE ${db} OWNER ${user};"`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
execSync("npm run db:migrate", { stdio: "inherit" });
|
||||||
|
execSync("npm run db:seed", { stdio: "inherit" });
|
||||||
|
console.log("✅ Database reset, migrated, and seeded");
|
||||||
Executable
+99
@@ -0,0 +1,99 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# End-to-end validation test for the local Postgres + PostgREST + MinIO + Next.js stack
|
||||||
|
# Exit 0 = all green, exit 1 = at least one failure.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
API="http://localhost:3001"
|
||||||
|
WEB="http://localhost:4000"
|
||||||
|
|
||||||
|
pass=0
|
||||||
|
fail=0
|
||||||
|
declare -a FAILURES
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local name="$1" url="$2" expected="${3:-200}" cookies="${4:-}"
|
||||||
|
local cmd="curl -s -o /dev/null -w '%{http_code}'"
|
||||||
|
if [ -n "$cookies" ]; then cmd="$cmd -b \"$cookies\""; fi
|
||||||
|
local code=$(eval "$cmd $url")
|
||||||
|
if [ "$code" = "$expected" ]; then
|
||||||
|
echo " PASS $name ($code)"
|
||||||
|
pass=$((pass+1))
|
||||||
|
else
|
||||||
|
echo " FAIL $name expected=$expected got=$code url=$url"
|
||||||
|
fail=$((fail+1))
|
||||||
|
FAILURES+=("$name")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Postgres connection ==="
|
||||||
|
PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT 'ok';" >/dev/null
|
||||||
|
echo " PASS postgres responds"
|
||||||
|
pass=$((pass+1))
|
||||||
|
|
||||||
|
echo "=== DB integrity ==="
|
||||||
|
TABLE_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';")
|
||||||
|
[ "$TABLE_COUNT" -ge 65 ] && echo " PASS $TABLE_COUNT public tables (>=65)" && pass=$((pass+1)) || { echo " FAIL $TABLE_COUNT tables"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
FN_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname='public';")
|
||||||
|
[ "$FN_COUNT" -ge 250 ] && echo " PASS $FN_COUNT functions (>=250)" && pass=$((pass+1)) || { echo " FAIL $FN_COUNT functions"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands;")
|
||||||
|
[ "$BRANDS" -ge 2 ] && echo " PASS $BRANDS brands" && pass=$((pass+1)) || { echo " FAIL $BRANDS brands"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
STOPS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM stops WHERE active=true AND deleted_at IS NULL;")
|
||||||
|
[ "$STOPS" -ge 200 ] && echo " PASS $STOPS active stops" && pass=$((pass+1)) || { echo " FAIL $STOPS stops"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
# No test brands
|
||||||
|
TEST_BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands WHERE slug IN ('sunrise-farms','green-valley','orchard-fresh');")
|
||||||
|
[ "$TEST_BRANDS" = "0" ] && echo " PASS no test brands" && pass=$((pass+1)) || { echo " FAIL test brands found"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
echo "=== Tuxedo Corn data ==="
|
||||||
|
PHONE=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT phone FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de';")
|
||||||
|
[ "$PHONE" = "970-323-6874" ] && echo " PASS phone=$PHONE" && pass=$((pass+1)) || { echo " FAIL phone=$PHONE (expected 970-323-6874)"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
LOGOS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de' AND logo_url LIKE '/storage/%';")
|
||||||
|
[ "$LOGOS" = "1" ] && echo " PASS logo_url is /storage/ path" && pass=$((pass+1)) || { echo " FAIL logo_url not /storage/"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
echo "=== PostgREST ==="
|
||||||
|
check "GET /" "$API/"
|
||||||
|
check "GET /brands" "$API/brands?select=id,name,slug&order=name"
|
||||||
|
check "GET /brands?slug=eq.tuxedo" "$API/brands?slug=eq.tuxedo&select=*"
|
||||||
|
check "GET /stops" "$API/stops?select=id,city&limit=5"
|
||||||
|
check "GET /products" "$API/products?select=id,name&limit=5"
|
||||||
|
check "POST rpc get_brand_settings_by_slug" "$API/rpc/get_brand_settings_by_slug" 200 \
|
||||||
|
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
|
||||||
|
check "POST rpc get_brand_plan_info" "$API/rpc/get_brand_plan_info" 200 \
|
||||||
|
"-X POST -H Content-Type:application/json -d {\"p_brand_id\":\"64294306-5f42-463d-a5e8-2ad6c81a96de\"}"
|
||||||
|
check "POST rpc get_public_stops_for_brand" "$API/rpc/get_public_stops_for_brand" 200 \
|
||||||
|
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
|
||||||
|
check "GET /admin_users with brand join" "$API/admin_users?select=id,user_id,role,brand_id,brands(name)&limit=5"
|
||||||
|
check "GET /stops with brand join" "$API/stops?select=id,city,brand_id,brands(name)&limit=5"
|
||||||
|
|
||||||
|
echo "=== MinIO ==="
|
||||||
|
check "Storage logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
|
||||||
|
check "Storage olathe-sweet-logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
|
||||||
|
check "Storage olathe-sweet-logo-dark.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"
|
||||||
|
|
||||||
|
echo "=== Public storefronts ==="
|
||||||
|
check "GET /" "$WEB/"
|
||||||
|
check "GET /tuxedo" "$WEB/tuxedo"
|
||||||
|
check "GET /tuxedo/about" "$WEB/tuxedo/about"
|
||||||
|
check "GET /tuxedo/stops" "$WEB/tuxedo/stops"
|
||||||
|
check "GET /indian-river-direct" "$WEB/indian-river-direct"
|
||||||
|
check "GET /login" "$WEB/login"
|
||||||
|
|
||||||
|
echo "=== Admin pages (dev_session=platform_admin) ==="
|
||||||
|
for p in /admin /admin/products /admin/stops /admin/orders /admin/users /admin/settings /admin/settings/billing /admin/settings/apps /admin/settings/payments /admin/communications /admin/communications/compose /admin/time-tracking /admin/wholesale /admin/water-log /admin/analytics /admin/reports; do
|
||||||
|
check "GET $p" "$WEB$p" 200 "dev_session=platform_admin"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Summary ==="
|
||||||
|
echo " PASS: $pass"
|
||||||
|
echo " FAIL: $fail"
|
||||||
|
if [ $fail -gt 0 ]; then
|
||||||
|
echo " Failures:"
|
||||||
|
for f in "${FAILURES[@]}"; do echo " - $f"; done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ALL GREEN"
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Apply Postgres migrations from `db/migrations/*.sql` in lexical order.
|
||||||
|
* Wraps the whole thing in a transaction; tracks applied files in
|
||||||
|
* `_migrations` so re-runs are safe.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm run db:migrate
|
||||||
|
*
|
||||||
|
* Replaces the old `supabase/push-migrations.js` — that script was
|
||||||
|
* hardcoded to a Supabase URL. This one reads `DATABASE_URL` directly.
|
||||||
|
*/
|
||||||
|
require("dotenv").config({ path: ".env.local" });
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { Client } = require("pg");
|
||||||
|
|
||||||
|
const MIGRATIONS_DIR = path.join(__dirname, "..", "db", "migrations");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.DATABASE_ADMIN_URL ?? process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
console.error("❌ DATABASE_URL (or DATABASE_ADMIN_URL) is not set in .env.local");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
|
filename TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(MIGRATIONS_DIR)
|
||||||
|
.filter((f) => f.endsWith(".sql"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log("No migration files found in db/migrations/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: applied } = await client.query(
|
||||||
|
`SELECT filename FROM _migrations`,
|
||||||
|
);
|
||||||
|
const appliedSet = new Set(applied.map((r) => r.filename));
|
||||||
|
|
||||||
|
let appliedNow = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
if (appliedSet.has(file)) {
|
||||||
|
console.log(`✓ ${file} (already applied)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), "utf8");
|
||||||
|
console.log(`→ Applying ${file}...`);
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query(sql);
|
||||||
|
await client.query(`INSERT INTO _migrations (filename) VALUES ($1)`, [
|
||||||
|
file,
|
||||||
|
]);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
appliedNow += 1;
|
||||||
|
console.log(`✓ ${file}`);
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
console.error(`✗ ${file} failed:`, err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n✅ Done. ${appliedNow} new migration(s) applied. ${
|
||||||
|
files.length - appliedNow
|
||||||
|
} already current.`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
seed_tuxedo_tour.py
|
||||||
|
|
||||||
|
Parses Tuxedo_Corn_2026_Tour_Schedule-3.xlsx and seeds the `stops` table for
|
||||||
|
the Tuxedo brand via the admin_create_stops_batch RPC.
|
||||||
|
|
||||||
|
Skips:
|
||||||
|
- Title / subtitle / legend rows (rows 1-3)
|
||||||
|
- Week header rows (col A = "Wk N", col D-J empty)
|
||||||
|
- Cross-Dock / Monday OFF rows (col D contains "OFF" or "Cross-Dock")
|
||||||
|
|
||||||
|
Joins with the Stop Directory sheet to enrich each stop with:
|
||||||
|
- address, phone, contact
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/seed_tuxedo_tour.py --dry-run # show what would be inserted
|
||||||
|
python3 scripts/seed_tuxedo_tour.py # actually insert
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
- supabase CLI linked to project wnzkhezyhnfzhkhiflrp
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"
|
||||||
|
YEAR = 2026
|
||||||
|
|
||||||
|
MONTH_MAP = {
|
||||||
|
"Jan": "01", "Feb": "02", "Mar": "03", "Apr": "04",
|
||||||
|
"May": "05", "Jun": "06", "Jul": "07", "Aug": "08",
|
||||||
|
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_XLSX = (
|
||||||
|
"/home/coder/dev/x1/kyle/route_commerce-main/"
|
||||||
|
"Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_excel_date(s):
|
||||||
|
"""'Jul 22' -> '2026-07-22'"""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
m = re.match(r"^([A-Za-z]{3})\s+(\d{1,2})$", str(s).strip())
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
mm = MONTH_MAP.get(m.group(1))
|
||||||
|
if not mm:
|
||||||
|
return None
|
||||||
|
return f"{YEAR}-{mm}-{int(m.group(2)):02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_range(s):
|
||||||
|
"""'10:00 AM - 1:00 PM' -> '10:00 AM' (start time)"""
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
cleaned = re.sub(r"[–—]", "-", str(s)).strip()
|
||||||
|
cleaned = re.sub(r"\s+", " ", cleaned)
|
||||||
|
m = re.match(r"^(\d{1,2}:\d{2}\s*[AP]M)", cleaned, re.IGNORECASE)
|
||||||
|
return m.group(1).upper().replace(" ", " ") if m else cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def split_city_state(s):
|
||||||
|
"""'Cheyenne, WY' -> ('Cheyenne', 'WY')"""
|
||||||
|
if not s:
|
||||||
|
return "", ""
|
||||||
|
parts = [p.strip() for p in str(s).split(",")]
|
||||||
|
if len(parts) == 1:
|
||||||
|
return parts[0], ""
|
||||||
|
return parts[0], parts[1]
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(s):
|
||||||
|
s = (s or "").lower()
|
||||||
|
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||||
|
return s.strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def is_week_header(row):
|
||||||
|
a = str(row[0] or "").strip()
|
||||||
|
d = str(row[3] or "").strip()
|
||||||
|
return re.match(r"^Wk\s", a) and d == ""
|
||||||
|
|
||||||
|
|
||||||
|
def is_off_row(row):
|
||||||
|
d = str(row[3] or "").strip()
|
||||||
|
return "OFF" in d or "Cross-Dock" in d or "Cross‑Dock" in d
|
||||||
|
|
||||||
|
|
||||||
|
def is_data_row(row):
|
||||||
|
d = str(row[3] or "").strip()
|
||||||
|
e = str(row[4] or "").strip()
|
||||||
|
if not d or not e:
|
||||||
|
return False
|
||||||
|
if "," not in e:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def load(xlsx_path):
|
||||||
|
wb = load_workbook(xlsx_path, data_only=True)
|
||||||
|
schedule = wb["Full Schedule"]
|
||||||
|
directory = wb["Stop Directory"]
|
||||||
|
|
||||||
|
# Build Stop Directory lookup: (truck, host_normalized) -> {address, phone, contact, ...}
|
||||||
|
dir_map = {}
|
||||||
|
for row in directory.iter_rows(min_row=2, values_only=True):
|
||||||
|
truck = str(row[0] or "").strip()
|
||||||
|
city = str(row[1] or "").strip()
|
||||||
|
state = str(row[2] or "").strip()
|
||||||
|
host = str(row[3] or "").strip()
|
||||||
|
address = str(row[4] or "").strip()
|
||||||
|
phone = str(row[5] or "").strip()
|
||||||
|
contact = str(row[6] or "").strip()
|
||||||
|
if not truck or not host:
|
||||||
|
continue
|
||||||
|
key = f"{truck}|{host.lower()}"
|
||||||
|
dir_map[key] = {
|
||||||
|
"city": city, "state": state, "host": host,
|
||||||
|
"address": address, "phone": phone, "contact": contact,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read Full Schedule (skip first 3 title/subtitle/legend rows)
|
||||||
|
stops = []
|
||||||
|
skipped = {"weekHeader": 0, "off": 0, "invalid": 0}
|
||||||
|
for row in schedule.iter_rows(min_row=4, values_only=True):
|
||||||
|
# Trim to 10 cols
|
||||||
|
cells = [("" if v is None else str(v).strip()) for v in row[:10]]
|
||||||
|
if is_week_header(cells):
|
||||||
|
skipped["weekHeader"] += 1
|
||||||
|
continue
|
||||||
|
if is_off_row(cells):
|
||||||
|
skipped["off"] += 1
|
||||||
|
continue
|
||||||
|
if not is_data_row(cells):
|
||||||
|
skipped["invalid"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
wk, region, date_text, day, city_state, host, time, truck, status, notes = cells
|
||||||
|
date_iso = parse_excel_date(date_text)
|
||||||
|
if not date_iso:
|
||||||
|
skipped["invalid"] += 1
|
||||||
|
continue
|
||||||
|
city, state = split_city_state(city_state)
|
||||||
|
if not city:
|
||||||
|
skipped["invalid"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Enrich from directory
|
||||||
|
dir_key = f"{truck}|{host.lower()}"
|
||||||
|
d = dir_map.get(dir_key)
|
||||||
|
|
||||||
|
stops.append({
|
||||||
|
"week": wk,
|
||||||
|
"region": region,
|
||||||
|
"date": date_iso,
|
||||||
|
"day": day,
|
||||||
|
"city": city,
|
||||||
|
"state": state or (d["state"] if d else ""),
|
||||||
|
"location": host,
|
||||||
|
"time": parse_time_range(time),
|
||||||
|
"time_range": time,
|
||||||
|
"truck": truck,
|
||||||
|
"status_text": status,
|
||||||
|
"notes": notes,
|
||||||
|
"address": d["address"] if d and d["address"] else None,
|
||||||
|
"phone": d["phone"] if d and d["phone"] else None,
|
||||||
|
"contact": d["contact"] if d and d["contact"] else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return stops, skipped, len(dir_map)
|
||||||
|
|
||||||
|
|
||||||
|
def assign_slugs(stops, dry_run):
|
||||||
|
used = set()
|
||||||
|
if not dry_run:
|
||||||
|
out = subprocess.run(
|
||||||
|
["supabase", "db", "query", "--linked",
|
||||||
|
f"SELECT slug FROM stops WHERE brand_id = '{TUXEDO_BRAND_ID}';"],
|
||||||
|
capture_output=True, text=True, timeout=120,
|
||||||
|
)
|
||||||
|
# Parse the table output - slugs are in second column between │
|
||||||
|
for m in re.finditer(r"│\s*([a-z0-9][a-z0-9-]*)\s*│", out.stdout):
|
||||||
|
used.add(m.group(1))
|
||||||
|
|
||||||
|
for s in stops:
|
||||||
|
base = f"{slugify(s['city'])}-{s['date']}"
|
||||||
|
slug = base
|
||||||
|
n = 0
|
||||||
|
while slug in used:
|
||||||
|
n += 1
|
||||||
|
slug = f"{base}-{n}"
|
||||||
|
used.add(slug)
|
||||||
|
s["slug"] = slug
|
||||||
|
|
||||||
|
|
||||||
|
def to_rpc_row(s):
|
||||||
|
return {
|
||||||
|
"city": s["city"],
|
||||||
|
"state": s["state"],
|
||||||
|
"location": s["location"],
|
||||||
|
"date": f"{s['date']} 00:00:00+00",
|
||||||
|
"time": s["time"],
|
||||||
|
"address": s["address"],
|
||||||
|
"zip": None,
|
||||||
|
"cutoff_time": None,
|
||||||
|
# active=true so the stops appear on the public storefront immediately.
|
||||||
|
# Matches the behavior of publishStop in src/actions/stops.ts.
|
||||||
|
"active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_payload_json(batch):
|
||||||
|
"""Build a clean JSON string for use in a SQL file."""
|
||||||
|
return json.dumps(batch, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_batch(batch):
|
||||||
|
"""Write SQL to a temp file and execute via --file to avoid shell escaping."""
|
||||||
|
payload_json = build_payload_json(batch)
|
||||||
|
sql = (
|
||||||
|
f"SELECT admin_create_stops_batch("
|
||||||
|
f"'{TUXEDO_BRAND_ID}'::uuid, "
|
||||||
|
f"$${payload_json}$$::jsonb);\n"
|
||||||
|
)
|
||||||
|
# Write to temp file
|
||||||
|
tmp_path = Path("/tmp/seed_tuxedo_tour.sql")
|
||||||
|
tmp_path.write_text(sql, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["supabase", "db", "query", "--linked", "--file", str(tmp_path)],
|
||||||
|
capture_output=True, text=True, timeout=300,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"RPC failed: {proc.stderr[:800]}")
|
||||||
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
ap.add_argument("--xlsx", default=DEFAULT_XLSX)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not Path(args.xlsx).exists():
|
||||||
|
sys.exit(f"XLSX not found: {args.xlsx}")
|
||||||
|
|
||||||
|
stops, skipped, dir_count = load(args.xlsx)
|
||||||
|
assign_slugs(stops, dry_run=args.dry_run)
|
||||||
|
|
||||||
|
print(f"\nParsed {len(stops)} stops "
|
||||||
|
f"(skipped: {skipped['weekHeader']} week-headers, "
|
||||||
|
f"{skipped['off']} OFF days, {skipped['invalid']} invalid)")
|
||||||
|
print(f"Stop Directory: {dir_count} entries loaded for enrichment\n")
|
||||||
|
|
||||||
|
if not stops:
|
||||||
|
sys.exit("No stops to insert.")
|
||||||
|
|
||||||
|
print("Sample (first 3):")
|
||||||
|
for s in stops[:3]:
|
||||||
|
print(f" {s['date']} {s['time']:10s} | {s['city']:18s}, {s['state']:2s} | "
|
||||||
|
f"{s['location'][:35]:35s} | {s['truck']} | {s['status_text']} | {s['slug']}")
|
||||||
|
if s["notes"]:
|
||||||
|
print(f" notes: {s['notes'][:120]}")
|
||||||
|
if s["address"]:
|
||||||
|
print(f" addr: {s['address']} ph: {s['phone']} ctc: {s['contact']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Show counts by week and region
|
||||||
|
by_week = {}
|
||||||
|
by_region = {}
|
||||||
|
by_truck = {}
|
||||||
|
for s in stops:
|
||||||
|
by_week[s["week"]] = by_week.get(s["week"], 0) + 1
|
||||||
|
by_region[s["region"]] = by_region.get(s["region"], 0) + 1
|
||||||
|
by_truck[s["truck"]] = by_truck.get(s["truck"], 0) + 1
|
||||||
|
print("By week:", dict(sorted(by_week.items())))
|
||||||
|
print("By region:", by_region)
|
||||||
|
print("By truck:", by_truck)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Date range
|
||||||
|
dates = sorted(s["date"] for s in stops)
|
||||||
|
print(f"Date range: {dates[0]} to {dates[-1]}\n")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
batches = (len(stops) + 49) // 50
|
||||||
|
print(f"[DRY RUN] Would insert {len(stops)} stops in {batches} batch(es) of 50.")
|
||||||
|
return
|
||||||
|
|
||||||
|
BATCH = 50
|
||||||
|
total = 0
|
||||||
|
batches = (len(stops) + BATCH - 1) // BATCH
|
||||||
|
for i in range(0, len(stops), BATCH):
|
||||||
|
batch = [to_rpc_row(s) for s in stops[i:i + BATCH]]
|
||||||
|
bnum = i // BATCH + 1
|
||||||
|
sys.stdout.write(f" Inserting batch {bnum}/{batches} ({len(batch)} stops)... ")
|
||||||
|
sys.stdout.flush()
|
||||||
|
try:
|
||||||
|
insert_batch(batch)
|
||||||
|
total += len(batch)
|
||||||
|
print("OK")
|
||||||
|
except Exception as e:
|
||||||
|
print("FAIL")
|
||||||
|
print(f" {e}")
|
||||||
|
|
||||||
|
# The batch RPC hardcodes status='draft' on insert. The Tuxedo storefront
|
||||||
|
# page only filters on active=true (not status), so active=true is enough
|
||||||
|
# to make stops visible. But for consistency with the publishStop server
|
||||||
|
# action — which sets both — flip status to 'active' for the rows we just
|
||||||
|
# inserted. Slug-based so we only touch stops from this run, not the
|
||||||
|
# pre-existing "Olathe" test stop.
|
||||||
|
if total > 0:
|
||||||
|
slugs = [s["slug"] for s in stops]
|
||||||
|
# Build a safe IN list (slug is a text column)
|
||||||
|
slug_list = ", ".join(f"'{slug.replace(chr(39), chr(39)+chr(39))}'" for slug in slugs)
|
||||||
|
publish_sql = (
|
||||||
|
f"UPDATE stops SET status = 'active' "
|
||||||
|
f"WHERE brand_id = '{TUXEDO_BRAND_ID}' "
|
||||||
|
f"AND slug IN ({slug_list});"
|
||||||
|
)
|
||||||
|
tmp = Path("/tmp/seed_tuxedo_publish.sql")
|
||||||
|
tmp.write_text(publish_sql, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["supabase", "db", "query", "--linked", "--file", str(tmp)],
|
||||||
|
capture_output=True, text=True, timeout=120,
|
||||||
|
)
|
||||||
|
print(f"\n Published {total} stops (status -> 'active').")
|
||||||
|
finally:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
print(f"\nDone. Inserted {total}/{len(stops)} stops for Tuxedo brand.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
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 }> {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,48 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { auth } from "@/lib/auth";
|
||||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current user's Supabase auth password.
|
||||||
|
*
|
||||||
|
* Reads the Auth.js v5 session to identify the user. The session's
|
||||||
|
* `user.id` is either:
|
||||||
|
* - a Supabase auth user id (UUID) for email/password sign-ins
|
||||||
|
* - a Google `sub` (non-UUID) for Google sign-ins — these are not
|
||||||
|
* provisioned in Supabase auth, so the RPC will reject them. Google
|
||||||
|
* users must be provisioned in Supabase auth separately.
|
||||||
|
*
|
||||||
|
* The password update itself runs as a SECURITY DEFINER PL/pgSQL function
|
||||||
|
* (`update_user_password`) inside the database, called directly via the
|
||||||
|
* shared `pg` pool. No Supabase REST hop required.
|
||||||
|
*/
|
||||||
export async function updatePasswordAction(
|
export async function updatePasswordAction(
|
||||||
newPassword: string
|
newPassword: string
|
||||||
): Promise<{ error?: string }> {
|
): Promise<{ error?: string; userId?: string }> {
|
||||||
const cookieStore = await cookies();
|
const session = await auth();
|
||||||
const uid =
|
const uid = session?.user?.id;
|
||||||
cookieStore.get("rc_auth_uid")?.value ??
|
|
||||||
cookieStore.get("rc_uid")?.value;
|
|
||||||
|
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
return { error: "Not authenticated. Please log in again." };
|
return { error: "Not authenticated. Please log in again." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = createServiceClient(
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
if (!UUID_REGEX.test(uid)) {
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
return {
|
||||||
);
|
error:
|
||||||
|
"Password change is not available for social sign-in accounts. Please contact an admin.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const { error } = await service.rpc("update_user_password", {
|
try {
|
||||||
p_user_id: uid,
|
// The RPC is SECURITY DEFINER and returns a single row (or raises).
|
||||||
p_password: newPassword,
|
// We SELECT it (rather than SELECT update_user_password(...)) so the
|
||||||
});
|
// call stays a normal parameterized query and we can read the result.
|
||||||
|
await query("SELECT update_user_password($1, $2)", [uid, newPassword]);
|
||||||
if (error) return { error: error.message };
|
return { userId: uid };
|
||||||
return {};
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to update password.";
|
||||||
|
return { error: message };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+243
-539
@@ -1,18 +1,15 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies, headers } from "next/headers";
|
import "server-only";
|
||||||
import { createServerClient } from "@supabase/ssr";
|
import { cookies } from "next/headers";
|
||||||
import { NextRequest } from "next/server";
|
import { pool, query } from "@/lib/db";
|
||||||
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";
|
import { getMockTableData, mockBrands } from "@/lib/mock-data";
|
||||||
|
|
||||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||||
|
|
||||||
export type AdminUserRow = {
|
export type AdminUserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string | null;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
phone_number: string | null;
|
phone_number: string | null;
|
||||||
@@ -75,169 +72,17 @@ export type UpdateAdminUserInput = {
|
|||||||
phone_number?: string | null;
|
phone_number?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── SSR client for authenticated requests ─────────────────────────────────
|
// ─── Row mapping ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
async function getAuthClient() {
|
// `admin_users` schema (after migration 204 + 034 + 037):
|
||||||
const cookieStore = await cookies();
|
// id, user_id, display_name, email, phone_number, role, brand_id,
|
||||||
const headerStore = await headers();
|
// can_manage_<X> (BOOLEAN each), active, must_change_password,
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// created_at, last_login, raw_user_meta_data, auth_provider, auth_subject
|
||||||
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="));
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
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";
|
|
||||||
if (rcAuthUid === DEV_FORCE_UID) {
|
|
||||||
return { data: null, error: null }; // let the action proceed without auth check
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: userData, error: userError } = await supabase.auth.getUser();
|
|
||||||
if (userError || !userData.user) {
|
|
||||||
return { data: null, error: "Not authenticated" };
|
|
||||||
}
|
|
||||||
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 }> {
|
|
||||||
if (process.env.NODE_ENV === "production") {
|
|
||||||
return { user: null, error: "Dev path not available in production" };
|
|
||||||
}
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const devSession = cookieStore.get("dev_session")?.value;
|
|
||||||
if (!devSession || devSession !== "platform_admin") {
|
|
||||||
return { user: null, error: "Not authenticated" };
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
email_confirm: true,
|
|
||||||
user_metadata: {
|
|
||||||
display_name: input.display_name || input.email.split("@")[0],
|
|
||||||
phone_number: input.phone_number ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (authError || !authUser.user) {
|
|
||||||
return { user: null, error: authError?.message ?? "Failed to create auth user" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert into admin_users
|
|
||||||
const { data: inserted, error: insertError } = await service
|
|
||||||
.from("admin_users")
|
|
||||||
.insert({
|
|
||||||
user_id: authUser.user.id,
|
|
||||||
role: input.role,
|
|
||||||
brand_id: input.brand_id,
|
|
||||||
display_name: input.display_name || input.email.split("@")[0],
|
|
||||||
phone_number: input.phone_number ?? null,
|
|
||||||
can_manage_products: input.flags.can_manage_products ?? false,
|
|
||||||
can_manage_stops: input.flags.can_manage_stops ?? false,
|
|
||||||
can_manage_orders: input.flags.can_manage_orders ?? false,
|
|
||||||
can_manage_pickup: input.flags.can_manage_pickup ?? false,
|
|
||||||
can_manage_messages: input.flags.can_manage_messages ?? false,
|
|
||||||
can_manage_refunds: input.flags.can_manage_refunds ?? false,
|
|
||||||
can_manage_users: input.flags.can_manage_users ?? false,
|
|
||||||
can_manage_water_log: input.flags.can_manage_water_log ?? false,
|
|
||||||
can_manage_reports: input.flags.can_manage_reports ?? false,
|
|
||||||
active: true,
|
|
||||||
must_change_password: input.mustChangePassword ?? true,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (insertError) {
|
|
||||||
return { user: null, error: insertError.message };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send welcome email
|
|
||||||
try {
|
|
||||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
|
||||||
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
|
||||||
await sendWelcomeEmail({
|
|
||||||
to: input.email,
|
|
||||||
name: input.display_name || input.email.split("@")[0],
|
|
||||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
|
||||||
brandName: "Tuxedo Corn",
|
|
||||||
tempPassword: input.password,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// welcome email failed silently
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: {
|
|
||||||
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,
|
|
||||||
brand_id: inserted.brand_id,
|
|
||||||
brand_name: null,
|
|
||||||
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,
|
|
||||||
last_login: null,
|
|
||||||
},
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapUserRow(row: Record<string, unknown>): AdminUserRow {
|
function mapUserRow(row: Record<string, unknown>): AdminUserRow {
|
||||||
return {
|
return {
|
||||||
id: String(row.id ?? ""),
|
id: String(row.id ?? ""),
|
||||||
user_id: String(row.user_id ?? ""),
|
user_id: (row.user_id as string | null) ?? null,
|
||||||
display_name: (row.display_name as string | null) ?? null,
|
display_name: (row.display_name as string | null) ?? null,
|
||||||
email: String(row.email ?? ""),
|
email: String(row.email ?? ""),
|
||||||
phone_number: (row.phone_number as string | null) ?? null,
|
phone_number: (row.phone_number as string | null) ?? null,
|
||||||
@@ -260,413 +105,272 @@ function mapUserRow(row: Record<string, unknown>): AdminUserRow {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Dev path helpers (service role, local only) ───────────────────────────
|
// ─── Welcome email (best-effort) ────────────────────────────────────────────
|
||||||
|
|
||||||
async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
async function sendWelcomeEmailSafe(input: {
|
||||||
const service = getServiceClient();
|
to: string;
|
||||||
|
name: string;
|
||||||
// Ensure caller has an admin_users record
|
role: "platform_admin" | "brand_admin" | "store_employee";
|
||||||
if (callerUid) {
|
password: string;
|
||||||
const { data: existing } = await service
|
}): Promise<void> {
|
||||||
.from("admin_users")
|
try {
|
||||||
.select("id")
|
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||||
.eq("user_id", callerUid)
|
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
||||||
.maybeSingle();
|
await sendWelcomeEmail({
|
||||||
|
to: input.to,
|
||||||
if (!existing) {
|
name: input.name,
|
||||||
// auto-creating admin_users for uid
|
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||||
const { data: authData } = await service.auth.admin.listUsers();
|
brandName: "Tuxedo Corn",
|
||||||
const authUser = authData?.users?.find((u) => u.id === callerUid);
|
tempPassword: input.password,
|
||||||
const meta = (authUser as { user_metadata?: Record<string, unknown> })?.user_metadata;
|
});
|
||||||
await service.from("admin_users").insert({
|
} catch {
|
||||||
user_id: callerUid,
|
// welcome email is best-effort; never block user creation
|
||||||
role: "platform_admin",
|
|
||||||
brand_id: 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,
|
|
||||||
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,
|
|
||||||
active: true,
|
|
||||||
must_change_password: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all admin_users rows (no RLS for service role)
|
|
||||||
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 };
|
|
||||||
|
|
||||||
// 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; 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) => {
|
|
||||||
const r = row as Record<string, unknown> & { brands?: { name?: string } };
|
|
||||||
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
|
|
||||||
return {
|
|
||||||
...mapUserRow(r),
|
|
||||||
email: authInfo.email || "No Email",
|
|
||||||
display_name: authInfo.display_name ?? null,
|
|
||||||
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
|
|
||||||
brand_name: r.brands?.name ?? null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return { users, error: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUsersFromRows(adminRows: Record<string, unknown>[], authUsers: { id: string; email?: string; user_metadata?: Record<string, unknown> }[]): { users: AdminUserRow[]; error: string | null } {
|
// ─── Public actions ─────────────────────────────────────────────────────────
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return { users, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Production admin actions (require real Supabase auth) ─────────────────
|
|
||||||
|
|
||||||
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
|
||||||
|
|
||||||
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
||||||
if (useMockData) {
|
if (useMockData) {
|
||||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
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;
|
|
||||||
|
|
||||||
// Read rc_auth_uid for force-login check
|
|
||||||
const cookieHeader = headerStore.get("cookie") || "";
|
|
||||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
|
||||||
.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 supabase.auth.getUser JWT validation.
|
|
||||||
if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) {
|
|
||||||
return devListAdminUsers(rcAuthUid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dev session cookie (platform_admin/brand_admin) — always use service role path
|
|
||||||
const isDevAdmin = process.env.NODE_ENV !== "production" && (
|
|
||||||
devSession === "platform_admin" || devSession === "brand_admin" || rcAuthUid === DEV_FORCE_UID
|
|
||||||
);
|
|
||||||
if (isDevAdmin) {
|
|
||||||
return devListAdminUsers(rcAuthUid ?? undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
|
||||||
// Read auth context
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const headerStore = await headers();
|
|
||||||
const devSession = cookieStore.get("dev_session")?.value;
|
|
||||||
const cookieHeader = headerStore.get("cookie") || "";
|
|
||||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
|
||||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
|
||||||
|
|
||||||
// DEV_FORCE_UID bypass — set by Emergency Force Login or /api/force-admin
|
|
||||||
if (rcAuthUid === DEV_FORCE_UID) {
|
|
||||||
return devCreateAdminUser(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDevAdmin = process.env.NODE_ENV !== "production" && devSession === "platform_admin";
|
|
||||||
|
|
||||||
// Dev path: use service role to create user without Supabase auth session
|
|
||||||
if (isDevAdmin) {
|
|
||||||
return devCreateAdminUser(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
const service = getServiceClient();
|
|
||||||
|
|
||||||
// Create auth user
|
|
||||||
const { data: authUser, error: authError } = await service.auth.admin.createUser({
|
|
||||||
email: input.email,
|
|
||||||
password: input.password,
|
|
||||||
email_confirm: true,
|
|
||||||
user_metadata: {
|
|
||||||
display_name: input.display_name || input.email.split("@")[0],
|
|
||||||
phone_number: input.phone_number ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (authError || !authUser.user) {
|
|
||||||
return { user: null, error: authError?.message ?? "Failed to create auth user" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert into admin_users
|
|
||||||
const { data: inserted, error: insertError } = await service
|
|
||||||
.from("admin_users")
|
|
||||||
.insert({
|
|
||||||
user_id: authUser.user.id,
|
|
||||||
role: input.role,
|
|
||||||
brand_id: input.brand_id,
|
|
||||||
display_name: input.display_name || input.email.split("@")[0],
|
|
||||||
phone_number: input.phone_number ?? null,
|
|
||||||
can_manage_products: input.flags.can_manage_products ?? false,
|
|
||||||
can_manage_stops: input.flags.can_manage_stops ?? false,
|
|
||||||
can_manage_orders: input.flags.can_manage_orders ?? false,
|
|
||||||
can_manage_pickup: input.flags.can_manage_pickup ?? false,
|
|
||||||
can_manage_messages: input.flags.can_manage_messages ?? false,
|
|
||||||
can_manage_refunds: input.flags.can_manage_refunds ?? false,
|
|
||||||
can_manage_users: input.flags.can_manage_users ?? false,
|
|
||||||
can_manage_water_log: input.flags.can_manage_water_log ?? false,
|
|
||||||
can_manage_reports: input.flags.can_manage_reports ?? false,
|
|
||||||
active: true,
|
|
||||||
must_change_password: input.mustChangePassword ?? true,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (insertError) return { user: null, error: insertError.message };
|
|
||||||
|
|
||||||
// Send welcome email
|
|
||||||
try {
|
|
||||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
|
||||||
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
|
||||||
await sendWelcomeEmail({
|
|
||||||
to: input.email,
|
|
||||||
name: input.display_name || input.email.split("@")[0],
|
|
||||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
|
||||||
brandName: "Tuxedo Corn",
|
|
||||||
tempPassword: input.password,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// welcome email failed silently
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
users: brandId ? mockUsers.filter((u) => u.brand_id === brandId) : mockUsers,
|
||||||
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,
|
|
||||||
brand_id: inserted.brand_id,
|
|
||||||
brand_name: null,
|
|
||||||
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,
|
|
||||||
last_login: null,
|
|
||||||
},
|
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user: null, error: "Not authenticated" };
|
try {
|
||||||
|
const sql = brandId
|
||||||
|
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
||||||
|
au.role, au.brand_id, b.name AS brand_name,
|
||||||
|
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
|
||||||
|
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
|
||||||
|
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
|
||||||
|
au.active, au.must_change_password, au.created_at, au.last_login
|
||||||
|
FROM admin_users au
|
||||||
|
LEFT JOIN brands b ON b.id = au.brand_id
|
||||||
|
WHERE au.brand_id = $1
|
||||||
|
ORDER BY au.created_at DESC`
|
||||||
|
: `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
||||||
|
au.role, au.brand_id, b.name AS brand_name,
|
||||||
|
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
|
||||||
|
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
|
||||||
|
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
|
||||||
|
au.active, au.must_change_password, au.created_at, au.last_login
|
||||||
|
FROM admin_users au
|
||||||
|
LEFT JOIN brands b ON b.id = au.brand_id
|
||||||
|
ORDER BY au.created_at DESC`;
|
||||||
|
const { rows } = await query<Record<string, unknown>>(sql, brandId ? [brandId] : []);
|
||||||
|
return { users: rows.map(mapUserRow), error: null };
|
||||||
|
} catch (err) {
|
||||||
|
return { users: [], error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||||
|
if (useMockData) {
|
||||||
|
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||||
|
const newRow: AdminUserRow = {
|
||||||
|
id: `mock-${Date.now()}`,
|
||||||
|
user_id: null,
|
||||||
|
display_name: input.display_name ?? input.email.split("@")[0],
|
||||||
|
email: input.email,
|
||||||
|
phone_number: input.phone_number ?? null,
|
||||||
|
role: input.role,
|
||||||
|
brand_id: input.brand_id,
|
||||||
|
brand_name: null,
|
||||||
|
can_manage_products: input.flags.can_manage_products ?? false,
|
||||||
|
can_manage_stops: input.flags.can_manage_stops ?? false,
|
||||||
|
can_manage_orders: input.flags.can_manage_orders ?? false,
|
||||||
|
can_manage_pickup: input.flags.can_manage_pickup ?? false,
|
||||||
|
can_manage_messages: input.flags.can_manage_messages ?? false,
|
||||||
|
can_manage_refunds: input.flags.can_manage_refunds ?? false,
|
||||||
|
can_manage_users: input.flags.can_manage_users ?? false,
|
||||||
|
can_manage_water_log: input.flags.can_manage_water_log ?? false,
|
||||||
|
can_manage_reports: input.flags.can_manage_reports ?? false,
|
||||||
|
active: true,
|
||||||
|
must_change_password: input.mustChangePassword ?? true,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
last_login: null,
|
||||||
|
};
|
||||||
|
mockUsers.push(newRow);
|
||||||
|
return { user: newRow, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// No Supabase Auth — `user_id` stays NULL until the user signs in
|
||||||
|
// via Auth.js and `get_admin_user_for_session` matches them by
|
||||||
|
// `auth_subject` / `email`. We just insert the row.
|
||||||
|
const f = input.flags;
|
||||||
|
const { rows } = await query<Record<string, unknown>>(
|
||||||
|
`INSERT INTO admin_users
|
||||||
|
(user_id, display_name, email, phone_number, role, brand_id,
|
||||||
|
can_manage_products, can_manage_stops, can_manage_orders,
|
||||||
|
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||||
|
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||||
|
active, must_change_password, auth_provider, auth_subject)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'pending',$17)
|
||||||
|
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
|
||||||
|
can_manage_products, can_manage_stops, can_manage_orders,
|
||||||
|
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||||
|
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||||
|
active, must_change_password, created_at, last_login`,
|
||||||
|
[
|
||||||
|
null,
|
||||||
|
input.display_name ?? input.email.split("@")[0],
|
||||||
|
input.email.toLowerCase(),
|
||||||
|
input.phone_number ?? null,
|
||||||
|
input.role,
|
||||||
|
input.brand_id,
|
||||||
|
f.can_manage_products ?? false,
|
||||||
|
f.can_manage_stops ?? false,
|
||||||
|
f.can_manage_orders ?? false,
|
||||||
|
f.can_manage_pickup ?? false,
|
||||||
|
f.can_manage_messages ?? false,
|
||||||
|
f.can_manage_refunds ?? false,
|
||||||
|
f.can_manage_users ?? false,
|
||||||
|
f.can_manage_water_log ?? false,
|
||||||
|
f.can_manage_reports ?? false,
|
||||||
|
input.mustChangePassword ?? true,
|
||||||
|
input.email.toLowerCase(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!rows[0]) return { user: null, error: "Insert returned no row" };
|
||||||
|
|
||||||
|
await sendWelcomeEmailSafe({
|
||||||
|
to: input.email,
|
||||||
|
name: input.display_name ?? input.email.split("@")[0],
|
||||||
|
role: input.role,
|
||||||
|
password: input.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user: mapUserRow(rows[0]), error: null };
|
||||||
|
} catch (err) {
|
||||||
|
return { user: null, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||||
// Dev bypass check
|
if (useMockData) {
|
||||||
const cookieStore = await cookies();
|
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||||
const headerStore = await headers();
|
const idx = mockUsers.findIndex((u) => u.id === input.id);
|
||||||
const devSession = cookieStore.get("dev_session")?.value;
|
if (idx === -1) return { user: null, error: "User not found" };
|
||||||
const cookieHeader = headerStore.get("cookie") || "";
|
const merged: AdminUserRow = { ...mockUsers[idx] };
|
||||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
if (input.role !== undefined) merged.role = input.role;
|
||||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
if (input.brand_id !== undefined) merged.brand_id = input.brand_id;
|
||||||
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
|
if (input.active !== undefined) merged.active = input.active;
|
||||||
if (rcAuthUid === DEV_FORCE_UID) {
|
if (input.display_name !== undefined) merged.display_name = input.display_name;
|
||||||
const service = getServiceClient();
|
if (input.phone_number !== undefined) merged.phone_number = input.phone_number;
|
||||||
const { data, error } = await service
|
if (input.flags) {
|
||||||
.from("admin_users")
|
for (const [k, v] of Object.entries(input.flags)) {
|
||||||
.update({
|
if (v !== undefined) (merged as Record<string, unknown>)[k] = v;
|
||||||
role: input.role ?? undefined,
|
}
|
||||||
brand_id: input.brand_id ?? undefined,
|
}
|
||||||
can_manage_products: input.flags?.can_manage_products ?? undefined,
|
mockUsers[idx] = merged;
|
||||||
can_manage_stops: input.flags?.can_manage_stops ?? undefined,
|
return { user: merged, error: null };
|
||||||
can_manage_orders: input.flags?.can_manage_orders ?? undefined,
|
|
||||||
can_manage_pickup: input.flags?.can_manage_pickup ?? undefined,
|
|
||||||
can_manage_messages: input.flags?.can_manage_messages ?? undefined,
|
|
||||||
can_manage_refunds: input.flags?.can_manage_refunds ?? undefined,
|
|
||||||
can_manage_users: input.flags?.can_manage_users ?? undefined,
|
|
||||||
can_manage_water_log: input.flags?.can_manage_water_log ?? undefined,
|
|
||||||
can_manage_reports: input.flags?.can_manage_reports ?? undefined,
|
|
||||||
active: input.active ?? undefined,
|
|
||||||
display_name: input.display_name ?? null,
|
|
||||||
phone_number: input.phone_number ?? null,
|
|
||||||
})
|
|
||||||
.eq("id", input.id)
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
if (error) return { user: null, error: error.message };
|
|
||||||
return { user: mapUserRow(data), error: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await callRpcWithAuth<AdminUserRow[]>("update_admin_user", {
|
try {
|
||||||
p_id: input.id,
|
// Build a partial SET clause. Each `can_manage_*` column is set
|
||||||
p_role: input.role ?? null,
|
// individually — the input's `flags` partial is spread across them.
|
||||||
p_brand_id: input.brand_id ?? null,
|
const sets: string[] = [];
|
||||||
p_flags: input.flags ?? null,
|
const params: unknown[] = [];
|
||||||
p_active: input.active ?? null,
|
const push = (col: string, val: unknown) => { params.push(val); sets.push(`${col} = $${params.length}`); };
|
||||||
p_display_name: input.display_name ?? null,
|
|
||||||
p_phone_number: input.phone_number ?? null,
|
if (input.role !== undefined) push("role", input.role);
|
||||||
});
|
if (input.brand_id !== undefined) push("brand_id", input.brand_id);
|
||||||
const rows = result.data as AdminUserRow[] | null;
|
if (input.active !== undefined) push("active", input.active);
|
||||||
return { user: rows?.[0] ?? null, error: result.error };
|
if (input.display_name !== undefined) push("display_name", input.display_name);
|
||||||
|
if (input.phone_number !== undefined) push("phone_number", input.phone_number);
|
||||||
|
if (input.flags) {
|
||||||
|
for (const [key, val] of Object.entries(input.flags)) {
|
||||||
|
if (val !== undefined) push(key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return { user: null, error: "Nothing to update" };
|
||||||
|
|
||||||
|
params.push(input.id);
|
||||||
|
const sql = `UPDATE admin_users SET ${sets.join(", ")}
|
||||||
|
WHERE id = $${params.length}
|
||||||
|
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
|
||||||
|
can_manage_products, can_manage_stops, can_manage_orders,
|
||||||
|
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||||
|
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||||
|
active, must_change_password, created_at, last_login`;
|
||||||
|
const { rows } = await query<Record<string, unknown>>(sql, params);
|
||||||
|
if (!rows[0]) return { user: null, error: "User not found" };
|
||||||
|
return { user: mapUserRow(rows[0]), error: null };
|
||||||
|
} catch (err) {
|
||||||
|
return { user: null, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
|
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
|
||||||
// Dev bypass check
|
if (useMockData) {
|
||||||
const cookieStore = await cookies();
|
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||||
const headerStore = await headers();
|
const idx = mockUsers.findIndex((u) => u.id === id);
|
||||||
const devSession = cookieStore.get("dev_session")?.value;
|
if (idx === -1) return { success: false, error: "User not found" };
|
||||||
const cookieHeader = headerStore.get("cookie") || "";
|
mockUsers.splice(idx, 1);
|
||||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
|
||||||
.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 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 service.from("admin_users").delete().eq("id", id);
|
|
||||||
if (deleteError) return { success: false, error: deleteError.message };
|
|
||||||
// Delete auth user
|
|
||||||
if (adminRow?.user_id) {
|
|
||||||
await service.auth.admin.deleteUser(adminRow.user_id);
|
|
||||||
}
|
|
||||||
return { success: true, error: null };
|
return { success: true, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await callRpcWithAuth<boolean>("delete_admin_user", { p_id: id });
|
try {
|
||||||
return { success: result.data ?? false, error: result.error };
|
// No Supabase Auth — nothing to delete from the auth service.
|
||||||
|
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
|
||||||
|
return { success: (rowCount ?? 0) > 0, error: null };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
|
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
|
||||||
const cookieStore = await cookies();
|
if (useMockData) {
|
||||||
const headerStore = await headers();
|
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||||
const cookieHeader = headerStore.get("cookie") || "";
|
const u = mockUsers.find((m) => m.id === userId);
|
||||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
if (!u) return { success: false, error: "User not found" };
|
||||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
u.must_change_password = true;
|
||||||
|
return { success: true, error: null };
|
||||||
if (rcAuthUid === DEV_FORCE_UID || process.env.NODE_ENV !== "production") {
|
|
||||||
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
|
try {
|
||||||
const service = getServiceClient();
|
const { rowCount } = await query(
|
||||||
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
|
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
|
||||||
return { success: !error, error: error?.message ?? null };
|
[userId],
|
||||||
|
);
|
||||||
|
return { success: (rowCount ?? 0) > 0, error: null };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendPasswordResetEmail(email: string): Promise<{ success: boolean; error: string | null }> {
|
/**
|
||||||
const { error } = await publicSupabase.auth.resetPasswordForEmail(email, {
|
* No auth service anymore (no Supabase, no Auth.js password-reset
|
||||||
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`,
|
* endpoint). A platform admin can reset access by deleting +
|
||||||
});
|
* re-creating the user, or by toggling `must_change_password` via the
|
||||||
return { success: !error, error: error?.message ?? null };
|
* UI — the function is preserved as a no-op so call sites keep
|
||||||
|
* compiling.
|
||||||
|
*/
|
||||||
|
export async function sendPasswordResetEmail(_email: string): Promise<{ success: boolean; error: string | null }> {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Password reset is handled by a platform admin. Contact them to reset your access.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
|
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
|
||||||
if (useMockData) {
|
if (useMockData) {
|
||||||
const brands = mockBrands.map(b => ({ id: b.id, name: b.name }));
|
return { brands: mockBrands.map((b) => ({ id: b.id, name: b.name })), error: null };
|
||||||
return { brands, error: null };
|
}
|
||||||
|
try {
|
||||||
|
const { rows } = await query<{ id: string; name: string }>(
|
||||||
|
`SELECT id, name FROM brands ORDER BY name`,
|
||||||
|
);
|
||||||
|
return { brands: rows, error: null };
|
||||||
|
} catch (err) {
|
||||||
|
return { brands: [], error: err instanceof Error ? err.message : String(err) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await publicSupabase.from("brands").select("id, name").order("name");
|
|
||||||
return { brands: data ?? [], error: error?.message ?? null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep `pool` reachable so bundlers don't tree-shake the import — the
|
||||||
|
// import is for the `server-only` side effect.
|
||||||
|
void pool;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser";
|
import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser";
|
||||||
import { importProductsBatch } from "@/actions/import-products";
|
import { importProductsBatch } from "@/actions/import-products";
|
||||||
import { importOrdersBatch } from "@/actions/import-orders";
|
import { importOrdersBatch } from "@/actions/import-orders";
|
||||||
@@ -41,7 +42,9 @@ export async function analyzeImport(
|
|||||||
): Promise<AnalyzeImportResult> {
|
): Promise<AnalyzeImportResult> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
try {
|
||||||
|
assertBrandAccess(adminUser, brandId);
|
||||||
|
} catch {
|
||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -96,7 +97,7 @@ async function brandScopedRPC<T>(
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const brandId = adminUser.brand_id ?? null;
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
|
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -232,7 +233,7 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const brandId = adminUser.brand_id ?? null;
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
select: "id,customer_name,subtotal,status,created_at,fulfillment",
|
select: "id,customer_name,subtotal,status,created_at,fulfillment",
|
||||||
@@ -293,7 +294,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const brandId = adminUser.brand_id ?? null;
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
select: "id,status",
|
select: "id,status",
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ type AuditResult =
|
|||||||
/**
|
/**
|
||||||
* Logs an audit event to the audit_logs table.
|
* Logs an audit event to the audit_logs table.
|
||||||
*
|
*
|
||||||
* In dev mode (dev_session cookie), uses the dev user identity.
|
* Resolves the admin user from the Auth.js session via getAdminUser().
|
||||||
* In production (Supabase auth), resolves the admin user from admin_users.
|
|
||||||
*
|
|
||||||
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
|
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
|
||||||
*/
|
*/
|
||||||
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
|
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import "server-only";
|
||||||
|
import { signIn, signOut } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kick off the Google OAuth flow. Auth.js will redirect to Google's
|
||||||
|
* consent screen and then back to /api/auth/callback/google, which sets
|
||||||
|
* the session cookie and redirects to /admin.
|
||||||
|
*/
|
||||||
|
export async function signInWithGoogle(): Promise<void> {
|
||||||
|
await signIn("google", { redirectTo: "/admin" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in with email + password. The `credentials` provider is enabled
|
||||||
|
* in dev (see `isDevLoginEnabled()` in `src/auth.config.ts`); in
|
||||||
|
* production it is omitted entirely and this action returns an
|
||||||
|
* `AuthError` (Auth.js surfaces `?error=Configuration` on /login).
|
||||||
|
*
|
||||||
|
* On a failed credential check Auth.js redirects back to
|
||||||
|
* /login?error=CredentialsSignin, which the LoginClient renders as
|
||||||
|
* "Invalid email or password."
|
||||||
|
*/
|
||||||
|
export async function signInWithCredentials(formData: FormData): Promise<void> {
|
||||||
|
const email = String(formData.get("email") ?? "").trim().toLowerCase();
|
||||||
|
const password = String(formData.get("password") ?? "");
|
||||||
|
if (!email || !password) {
|
||||||
|
redirect("/login?error=MissingCredentials");
|
||||||
|
}
|
||||||
|
await signIn("credentials", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirectTo: "/admin",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign out and clear the Auth.js session cookie.
|
||||||
|
*/
|
||||||
|
export async function signOutAction(): Promise<void> {
|
||||||
|
await signOut({ redirectTo: "/login" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { signIn, signOut } from "@/lib/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>
|
||||||
|
*
|
||||||
|
* Note: dev/demo authentication is no longer a button on the login page.
|
||||||
|
* `src/middleware.ts` auto-issues the `dev_session` cookie for /admin/*
|
||||||
|
* when ALLOW_DEV_LOGIN is enabled. See CLAUDE.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function signInWithGoogle(): Promise<void> {
|
||||||
|
await signIn("google", { redirectTo: "/admin" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOutAction(): Promise<void> {
|
||||||
|
await signOut({ redirectTo: "/login" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Stripe PaymentIntent for the supplied cart so the browser
|
||||||
|
* can confirm the payment with embedded Stripe Elements (Apple Pay /
|
||||||
|
* Google Pay / card) without redirecting to a hosted page.
|
||||||
|
*
|
||||||
|
* Mirrors `createRetailStripeCheckoutSession` in `retail-checkout.ts`:
|
||||||
|
* the PaymentIntent is the embedded equivalent of the Checkout Session.
|
||||||
|
* Order creation itself still happens on `/checkout/success` so the
|
||||||
|
* webhooks and order pipeline don't need to know which path the buyer
|
||||||
|
* used.
|
||||||
|
*/
|
||||||
|
type LineItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomerInfo = {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreatePaymentIntentResult =
|
||||||
|
| { success: true; clientSecret: string; paymentIntentId: string; amount: number }
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
export async function createRetailPaymentIntent(
|
||||||
|
items: LineItem[],
|
||||||
|
customer: CustomerInfo,
|
||||||
|
brandId: string | null,
|
||||||
|
stopId: string | null,
|
||||||
|
shippingAddress?: { state?: string; postal_code?: string; city?: string } | null
|
||||||
|
): Promise<CreatePaymentIntentResult> {
|
||||||
|
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||||
|
if (!stripeKey) {
|
||||||
|
return { success: false, error: "Stripe not configured on this server." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return { success: false, error: "Cart is empty." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const Stripe = (await import("stripe")).default;
|
||||||
|
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||||
|
|
||||||
|
// Compute the subtotal in cents. We don't compute sales tax here —
|
||||||
|
// Stripe's `automatic_tax` would be ideal but requires address collection
|
||||||
|
// client-side; for this single-product corn box the price is tax-inclusive
|
||||||
|
// (see Tuxedo Corn product card) so we treat the line total as final.
|
||||||
|
const amount = items.reduce((sum, item) => {
|
||||||
|
const qty = Math.max(1, Math.floor(item.quantity || 1));
|
||||||
|
const unit = Math.max(0, Math.round(Number(item.price) * 100));
|
||||||
|
return sum + unit * qty;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
if (amount <= 0) {
|
||||||
|
return { success: false, error: "Cart total must be greater than $0." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the brand name for Stripe receipts + metadata
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
let brandName = "Route Commerce";
|
||||||
|
if (supabaseUrl && supabaseKey && brandId) {
|
||||||
|
try {
|
||||||
|
const brandRes = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
|
||||||
|
{ headers: { ...svcHeaders(supabaseKey) } }
|
||||||
|
);
|
||||||
|
const brands = (await brandRes.json()) as Array<{ name: string }>;
|
||||||
|
if (brands?.[0]?.name) brandName = brands[0].name;
|
||||||
|
} catch {
|
||||||
|
// ignore — use default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a short human-readable description for the Stripe dashboard
|
||||||
|
const description = items
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((i) => `${i.quantity}× ${i.name}`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const intent = await stripe.paymentIntents.create({
|
||||||
|
amount,
|
||||||
|
currency: "usd",
|
||||||
|
automatic_payment_methods: { enabled: true },
|
||||||
|
receipt_email: customer.email || undefined,
|
||||||
|
description,
|
||||||
|
metadata: {
|
||||||
|
brand_id: brandId ?? "unknown",
|
||||||
|
brand_name: brandName,
|
||||||
|
stop_id: stopId ?? "",
|
||||||
|
customer_name: customer.name ?? "",
|
||||||
|
customer_email: customer.email ?? "",
|
||||||
|
shipping_state: shippingAddress?.state ?? "",
|
||||||
|
shipping_postal_code: shippingAddress?.postal_code ?? "",
|
||||||
|
shipping_city: shippingAddress?.city ?? "",
|
||||||
|
item_count: String(items.reduce((s, i) => s + i.quantity, 0)),
|
||||||
|
source: "storefront_express",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!intent.client_secret) {
|
||||||
|
return { success: false, error: "Stripe did not return a client secret." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
clientSecret: intent.client_secret,
|
||||||
|
paymentIntentId: intent.id,
|
||||||
|
amount: intent.amount,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to create payment intent.";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -271,25 +271,36 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
|
|||||||
|
|
||||||
// Public version for storefront pages — uses slug, no auth required
|
// Public version for storefront pages — uses slug, no auth required
|
||||||
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
const response = await fetch(
|
if (!supabaseUrl || !supabaseKey) {
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
|
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
|
||||||
{
|
}
|
||||||
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 };
|
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
|
||||||
const data = await response.json();
|
// doesn't crash the prerender — the page just falls back to its
|
||||||
return {
|
// default brand name and revalidates from a real request later.
|
||||||
success: true,
|
try {
|
||||||
settings: data,
|
const response = await fetch(
|
||||||
wholesaleEnabled: data?.wholesale_enabled,
|
`${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,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveBrandSettings(params: {
|
export async function saveBrandSettings(params: {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
|
export type BrandListItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
logo_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of brands the current admin user can act in.
|
||||||
|
*
|
||||||
|
* - platform_admin: all brands (queried directly from the `brands` table)
|
||||||
|
* - everyone else: brands in `adminUser.brand_ids`
|
||||||
|
* - empty array for unauthenticated / no-access admins
|
||||||
|
*
|
||||||
|
* This is a plain async function (not a server action) so it can be called
|
||||||
|
* from server components and server actions without the "use server" wrapper.
|
||||||
|
* The BrandSelector client component receives the result as a prop.
|
||||||
|
*/
|
||||||
|
export async function listBrandsForAdmin(): Promise<BrandListItem[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
if (!supabaseUrl || !serviceKey) return [];
|
||||||
|
|
||||||
|
if (adminUser.role === "platform_admin") {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
|
||||||
|
{ headers: svcHeaders(serviceKey), cache: "no-store" }
|
||||||
|
);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser.brand_ids.length === 0) return [];
|
||||||
|
|
||||||
|
// Use PostgREST `in` filter. The brand_ids are UUIDs, so the quoting
|
||||||
|
// pattern in the spec is safe; the inner quotes are required by PostgREST
|
||||||
|
// for UUID literals.
|
||||||
|
const filter = `id=in.(${adminUser.brand_ids
|
||||||
|
.map((id) => `"${id}"`)
|
||||||
|
.join(",")})`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
|
||||||
|
{ headers: svcHeaders(serviceKey), cache: "no-store" }
|
||||||
|
);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||||
@@ -57,7 +58,11 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
@@ -147,7 +152,7 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: adminUser.brand_id ?? null }),
|
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -167,7 +172,7 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: brandId ?? adminUser.brand_id ?? null }),
|
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser, brandId)) ?? null }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
import type { AudienceRules } from "./campaigns";
|
import type { AudienceRules } from "./campaigns";
|
||||||
|
|
||||||
@@ -120,11 +121,15 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
// Resolve brand from campaign or parameter
|
// Resolve brand from campaign or parameter (URL > cookie > legacy > first of brand_ids)
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
|
||||||
// Brand scoping: brand_admin can only send their own brand's campaigns
|
// Brand scoping: brand_admin can only send their own brand's campaigns
|
||||||
if (adminUser.role === "brand_admin" && effectiveBrandId && adminUser.brand_id !== effectiveBrandId) {
|
if (adminUser.role === "brand_admin" && effectiveBrandId && !adminUser.brand_ids.includes(effectiveBrandId)) {
|
||||||
return { success: false, error: "Not authorized to send this brand's campaigns" };
|
return { success: false, error: "Not authorized to send this brand's campaigns" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
import type { AudienceRules } from "./campaigns";
|
import type { AudienceRules } from "./campaigns";
|
||||||
|
|
||||||
@@ -33,7 +34,11 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
|
||||||
// Brand scoping: brand_admin can only see their own brand's templates
|
// Brand scoping: brand_admin can only see their own brand's templates
|
||||||
if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) {
|
if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) {
|
||||||
@@ -112,7 +117,7 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ p_brand_id: adminUser.brand_id ?? null }),
|
body: JSON.stringify({ p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -66,7 +67,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const brandId = adminUser.brand_id ?? null;
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
// Get today's date range
|
// Get today's date range
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -202,7 +203,7 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const brandId = adminUser.brand_id ?? null;
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
const thirtyDaysAgo = new Date();
|
const thirtyDaysAgo = new Date();
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidateTag } from "next/cache";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
|
export type LocationInput = {
|
||||||
|
name: string;
|
||||||
|
address?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
zip?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
contact_name?: string | null;
|
||||||
|
contact_email?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Location = {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
zip: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
contact_name: string | null;
|
||||||
|
contact_email: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
active: boolean;
|
||||||
|
deleted_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
slug: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocationWithCount = Location & { stop_count: number };
|
||||||
|
|
||||||
|
// ── Create (single) ──────────────────────────────────────────────────────────
|
||||||
|
export async function createLocation(
|
||||||
|
brandId: string,
|
||||||
|
input: LocationInput
|
||||||
|
): Promise<{ success: true; id: string; slug: string } | { success: false; error: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_stops) {
|
||||||
|
return { success: false, error: "Not authorized to manage locations" };
|
||||||
|
}
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
if (!effectiveBrandId) return { success: false, error: "No brand selected" };
|
||||||
|
|
||||||
|
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/admin_create_location`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
p_brand_id: effectiveBrandId,
|
||||||
|
p_name: input.name,
|
||||||
|
p_address: input.address ?? null,
|
||||||
|
p_city: input.city ?? null,
|
||||||
|
p_state: input.state ?? null,
|
||||||
|
p_zip: input.zip ?? null,
|
||||||
|
p_phone: input.phone ?? null,
|
||||||
|
p_contact_name: input.contact_name ?? null,
|
||||||
|
p_contact_email: input.contact_email ?? null,
|
||||||
|
p_notes: input.notes ?? null,
|
||||||
|
p_active: input.active ?? true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||||
|
return { success: false, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
revalidateTag("locations", "default");
|
||||||
|
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||||
|
return { success: true, id: data.id, slug: data.slug };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create (batch) ───────────────────────────────────────────────────────────
|
||||||
|
export async function createLocationsBatch(
|
||||||
|
brandId: string,
|
||||||
|
locations: LocationInput[]
|
||||||
|
): Promise<{ success: boolean; created: number; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, created: 0, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_stops) {
|
||||||
|
return { success: false, created: 0, error: "Not authorized" };
|
||||||
|
}
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, created: 0, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
|
||||||
|
|
||||||
|
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/admin_create_locations_batch`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_locations: locations }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||||
|
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await res.json();
|
||||||
|
revalidateTag("locations", "default");
|
||||||
|
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
created: Array.isArray(inserted) ? inserted.length : locations.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update (partial) ─────────────────────────────────────────────────────────
|
||||||
|
export async function updateLocation(
|
||||||
|
locationId: string,
|
||||||
|
brandId: string,
|
||||||
|
updates: Partial<LocationInput>
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
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_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/admin_update_location`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId, p_updates: updates }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||||
|
return { success: false, error: (err as { message?: string }).message ?? "Update failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) return { success: false, error: data.error ?? "Update failed" };
|
||||||
|
|
||||||
|
revalidateTag("locations", "default");
|
||||||
|
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete (soft) ────────────────────────────────────────────────────────────
|
||||||
|
export async function deleteLocation(
|
||||||
|
locationId: string,
|
||||||
|
brandId: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
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_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/admin_delete_location`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||||
|
return { success: false, error: (err as { message?: string }).message ?? "Delete failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) return { success: false, error: data.error ?? "Delete failed" };
|
||||||
|
|
||||||
|
revalidateTag("locations", "default");
|
||||||
|
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read (admin, by brand_id) ────────────────────────────────────────────────
|
||||||
|
export async function adminListLocations(
|
||||||
|
brandId: string
|
||||||
|
): Promise<LocationWithCount[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|
||||||
|
// Application-layer brand scoping: platform_admin (brand_id: null) gets all
|
||||||
|
// brands; everyone else gets only their own brand.
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/admin_list_locations`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||||
|
next: { revalidate: 60, tags: ["locations", `brand:${effectiveBrandId ?? "all"}:locations`] },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? (data as LocationWithCount[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read (public, by brand slug) ─────────────────────────────────────────────
|
||||||
|
export type PublicLocation = Pick<
|
||||||
|
Location,
|
||||||
|
"id" | "name" | "address" | "city" | "state" | "zip" | "phone" | "slug"
|
||||||
|
> & { stop_count: number };
|
||||||
|
|
||||||
|
export async function getPublicLocationsForBrand(
|
||||||
|
brandSlug: string
|
||||||
|
): Promise<PublicLocation[]> {
|
||||||
|
if (!brandSlug) return [];
|
||||||
|
|
||||||
|
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_locations_for_brand`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||||
|
next: { revalidate: 300, tags: ["locations", `brand:${brandSlug}:locations`] },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? (data as PublicLocation[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attach a stop to a location ──────────────────────────────────────────────
|
||||||
|
export async function attachStopToLocation(
|
||||||
|
stopId: string,
|
||||||
|
locationId: string,
|
||||||
|
brandId: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
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_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/admin_attach_location_to_stop`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
p_stop_id: stopId,
|
||||||
|
p_location_id: locationId,
|
||||||
|
p_brand_id: brandId,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||||
|
return { success: false, error: (err as { message?: string }).message ?? "Attach failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) return { success: false, error: data.error ?? "Attach failed" };
|
||||||
|
|
||||||
|
revalidateTag("stops", "default");
|
||||||
|
revalidateTag("locations", "default");
|
||||||
|
revalidateTag(`brand:${brandId}:stops`, "default");
|
||||||
|
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { createServerClient } from "@supabase/ssr";
|
|
||||||
|
|
||||||
export type LoginWithPasswordResult =
|
|
||||||
| { success: true; redirect: true }
|
|
||||||
| { success: false; error: string };
|
|
||||||
|
|
||||||
export async function loginWithPassword(
|
|
||||||
email: string,
|
|
||||||
password: string
|
|
||||||
): Promise<LoginWithPasswordResult> {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
@@ -41,7 +42,11 @@ export async function createAdminOrder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Brand scoping
|
// Brand scoping
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id && effectiveBrandId !== adminUser.brand_id) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id && effectiveBrandId !== adminUser.brand_id) {
|
||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export type PaymentProvider = "stripe" | "square" | "manual";
|
export type PaymentProvider = "stripe" | "square" | "manual";
|
||||||
@@ -65,10 +66,9 @@ export async function savePaymentSettings(params: {
|
|||||||
return { success: false, error: "Not authorized" };
|
return { success: false, error: "Not authorized" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
try {
|
||||||
adminUser.role === "brand_admin" &&
|
assertBrandAccess(adminUser, params.brandId);
|
||||||
adminUser.brand_id !== params.brandId
|
} catch {
|
||||||
) {
|
|
||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { logAuditEvent } from "@/actions/audit";
|
|||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
type MarkPickupResult =
|
type MarkPickupResult =
|
||||||
| { success: true; pickup_completed_at: string; pickup_completed_by: string }
|
| { success: true; pickup_completed_at: string; pickup_completed_by: string | null }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
export async function markPickupComplete(
|
export async function markPickupComplete(
|
||||||
@@ -23,6 +23,9 @@ export async function markPickupComplete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
// `user_id` is null for Google-authenticated admins who haven't been
|
||||||
|
// linked to a Supabase auth user yet. Pass null through; downstream
|
||||||
|
// audit/assignment RPCs will surface a clearer error.
|
||||||
const performedBy = adminUser.user_id;
|
const performedBy = adminUser.user_id;
|
||||||
|
|
||||||
// brand_admin: verify the order belongs to their brand
|
// brand_admin: verify the order belongs to their brand
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export async function deleteProduct(
|
export async function deleteProduct(
|
||||||
@@ -16,7 +17,11 @@ export async function deleteProduct(
|
|||||||
return { success: false, error: "Not authorized to manage products" };
|
return { success: false, error: "Not authorized to manage products" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ export async function uploadProductImage(
|
|||||||
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
|
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Authorization": `Bearer ${supabaseKey}`, "Content-Type": `image/${ext}`, "x-upsert": "true" },
|
headers: {
|
||||||
|
"apikey": supabaseKey,
|
||||||
|
"Authorization": `Bearer ${supabaseKey}`,
|
||||||
|
"Content-Type": `image/${ext}`,
|
||||||
|
"x-upsert": "true"
|
||||||
|
},
|
||||||
body: buffer,
|
body: buffer,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -153,7 +154,14 @@ export interface FieldYieldSummary {
|
|||||||
export async function getRouteTraceLots(brandId: string, status?: string) {
|
export async function getRouteTraceLots(brandId: string, status?: string) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -235,7 +243,14 @@ export async function createHarvestLot(
|
|||||||
) {
|
) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -298,7 +313,14 @@ export async function updateHarvestLotStatus(
|
|||||||
export async function getRouteTraceStats(brandId: string) {
|
export async function getRouteTraceStats(brandId: string) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -318,7 +340,14 @@ export async function getRouteTraceStats(brandId: string) {
|
|||||||
export async function searchHarvestLots(brandId: string, query: string) {
|
export async function searchHarvestLots(brandId: string, query: string) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -347,7 +376,14 @@ export async function getTraceChain(lotId: string) {
|
|||||||
export async function getHarvestLotsReadyToHaul(brandId: string) {
|
export async function getHarvestLotsReadyToHaul(brandId: string) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -364,7 +400,14 @@ export async function getHarvestLotsReadyToHaul(brandId: string) {
|
|||||||
export async function getFieldYieldSummary(brandId: string) {
|
export async function getFieldYieldSummary(brandId: string) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -405,7 +448,14 @@ export interface InventoryByCrop {
|
|||||||
export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> {
|
export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -451,7 +501,14 @@ export async function getRecentLotEvents(
|
|||||||
): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> {
|
): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import {
|
||||||
|
setActiveBrandCookie,
|
||||||
|
clearActiveBrandCookie,
|
||||||
|
} from "@/lib/brand-scope";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the persistent "active brand" for the current admin user.
|
||||||
|
*
|
||||||
|
* - `brandId === null`: "All brands" — only allowed for platform_admin.
|
||||||
|
* Clears the cookie (cookie absence = no specific brand pinned).
|
||||||
|
* - `brandId` string: sets the cookie, after validating the admin has access.
|
||||||
|
*
|
||||||
|
* The active brand is the default the UI uses for pages that don't receive
|
||||||
|
* an explicit `brandId` from the URL. The cookie is the source of truth —
|
||||||
|
* the URL is only for deep-linking.
|
||||||
|
*/
|
||||||
|
export async function setActiveBrand(
|
||||||
|
brandId: string | null
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
|
// null = "All brands" (platform_admin only)
|
||||||
|
if (brandId === null) {
|
||||||
|
if (adminUser.role !== "platform_admin") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Only platform admins can select 'All brands'",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await clearActiveBrandCookie();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
adminUser.role !== "platform_admin" &&
|
||||||
|
!adminUser.brand_ids.includes(brandId)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "No access to that brand" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await setActiveBrandCookie(brandId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export type UpdateShippingStatusResult =
|
export type UpdateShippingStatusResult =
|
||||||
@@ -28,7 +29,7 @@ export async function updateShippingStatus(
|
|||||||
p_order_id: orderId,
|
p_order_id: orderId,
|
||||||
p_shipping_status: status,
|
p_shipping_status: status,
|
||||||
p_tracking_number: trackingNumber ?? null,
|
p_tracking_number: trackingNumber ?? null,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: await getActiveBrandId(adminUser),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -79,7 +80,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: await getActiveBrandId(adminUser),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
import type { FedExServiceType } from "./fedex-rates";
|
import type { FedExServiceType } from "./fedex-rates";
|
||||||
|
|
||||||
@@ -156,6 +157,10 @@ export async function createFedExShipment(
|
|||||||
const order = orders[0];
|
const order = orders[0];
|
||||||
if (!order) return { success: false, error: "Order not found" };
|
if (!order) return { success: false, error: "Order not found" };
|
||||||
|
|
||||||
|
// The order's brand is the source of truth. Validate the admin has access.
|
||||||
|
if (order.brand_id) {
|
||||||
|
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||||
|
|
||||||
// Get FedEx settings
|
// Get FedEx settings
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -177,6 +178,10 @@ export async function getFedExRates(
|
|||||||
const order = orders[0];
|
const order = orders[0];
|
||||||
if (!order) return { success: false, error: "Order not found" };
|
if (!order) return { success: false, error: "Order not found" };
|
||||||
|
|
||||||
|
// The order's brand is the source of truth. Validate the admin has access.
|
||||||
|
if (order.brand_id) {
|
||||||
|
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||||
|
|
||||||
// Fetch shipping settings for this brand
|
// Fetch shipping settings for this brand
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export type SyncLogEntry = {
|
export type SyncLogEntry = {
|
||||||
@@ -29,7 +30,9 @@ export async function syncSquareNow(
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
|
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, synced: 0, errors: ["Not authorized"] };
|
if (!adminUser.can_manage_orders) return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
try {
|
||||||
|
assertBrandAccess(adminUser, brandId);
|
||||||
|
} catch {
|
||||||
return { success: false, synced: 0, errors: ["Not authorized"] };
|
return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +64,9 @@ export async function getSyncLog(brandId: string): Promise<{
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, logs: [] };
|
if (!adminUser) return { success: false, logs: [] };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, logs: [] };
|
if (!adminUser.can_manage_orders) return { success: false, logs: [] };
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
try {
|
||||||
|
assertBrandAccess(adminUser, brandId);
|
||||||
|
} catch {
|
||||||
return { success: false, logs: [] };
|
return { success: false, logs: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-31
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export type StopImportRow = {
|
export type StopImportRow = {
|
||||||
@@ -25,7 +26,11 @@ export async function createStopsBatch(
|
|||||||
return { success: false, created: 0, error: "Not authorized to manage stops" };
|
return { success: false, created: 0, error: "Not authorized to manage stops" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveBrandId = brandId || adminUser.brand_id;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, created: 0, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
if (!effectiveBrandId) {
|
if (!effectiveBrandId) {
|
||||||
return { success: false, created: 0, error: "No brand selected" };
|
return { success: false, created: 0, error: "No brand selected" };
|
||||||
}
|
}
|
||||||
@@ -105,22 +110,30 @@ export type StopForSitemap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
// Get all active stops with their brand slug
|
if (!supabaseUrl || !supabaseKey) return [];
|
||||||
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 [];
|
// 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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const stops = await response.json();
|
if (!response.ok) return [];
|
||||||
return Array.isArray(stops) ? stops : [];
|
|
||||||
|
const stops = await response.json();
|
||||||
|
return Array.isArray(stops) ? stops : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,24 +163,33 @@ export async function getPublicStopsForBrand(
|
|||||||
): Promise<PublicStop[]> {
|
): Promise<PublicStop[]> {
|
||||||
if (!brandSlug) return [];
|
if (!brandSlug) return [];
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
const response = await fetch(
|
if (!supabaseUrl || !supabaseKey) return [];
|
||||||
`${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 [];
|
// 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`],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const stops = await response.json();
|
if (!response.ok) return [];
|
||||||
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
|
||||||
|
const stops = await response.json();
|
||||||
|
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
export type StopDetail = {
|
||||||
|
id: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
location: string;
|
||||||
|
slug: string;
|
||||||
|
active: boolean;
|
||||||
|
brand_id: string;
|
||||||
|
address: string | null;
|
||||||
|
zip: string | null;
|
||||||
|
cutoff_time: string | null;
|
||||||
|
brands: { name: string; slug: string } | { name: string; slug: string }[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssignedProduct = {
|
||||||
|
id: string;
|
||||||
|
product_id: string;
|
||||||
|
products: { name: string; type: string; price: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StopDetailsResult =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
stop: StopDetail;
|
||||||
|
allProducts: { id: string; name: string; type: string; price: number }[];
|
||||||
|
assignedProducts: AssignedProduct[];
|
||||||
|
brands: { id: string; name: string; slug: string }[];
|
||||||
|
/** admin_users.user_id of the caller, forwarded to RPCs that authorise via user_id lookup */
|
||||||
|
callerUid: string;
|
||||||
|
}
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single stop with its brand, all candidate products, currently
|
||||||
|
* assigned products, and the list of brands (for the brand switcher in the
|
||||||
|
* edit form). Mirrors the data the old `/admin/stops/[id]` page server
|
||||||
|
* component loaded, so the modal can be a drop-in replacement.
|
||||||
|
*/
|
||||||
|
export async function getStopDetails(stopId: string): Promise<StopDetailsResult> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
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_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||||
|
|
||||||
|
// Use a fresh server-side client (cookie-less) so RLS doesn't block reads
|
||||||
|
// for platform_admin dev sessions. The auth check above has already gated
|
||||||
|
// access.
|
||||||
|
const server = useMockData
|
||||||
|
? null
|
||||||
|
: createClient(supabaseUrl, supabaseKey, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Stop + brand
|
||||||
|
let stop: StopDetail | null = null;
|
||||||
|
let stopErr: string | null = null;
|
||||||
|
|
||||||
|
if (server) {
|
||||||
|
const { data, error } = await server
|
||||||
|
.from("stops")
|
||||||
|
.select("*, brands(name, slug)")
|
||||||
|
.eq("id", stopId)
|
||||||
|
.single();
|
||||||
|
if (error) stopErr = error.message;
|
||||||
|
else stop = (data ?? null) as StopDetail | null;
|
||||||
|
} else {
|
||||||
|
// Mock fallback — empty
|
||||||
|
stopErr = "Stop not found";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stop) {
|
||||||
|
return { success: false, error: stopErr ?? "Stop not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brand-scope check for brand_admin
|
||||||
|
if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) {
|
||||||
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Candidate products for this brand
|
||||||
|
const { data: allProducts } = server
|
||||||
|
? await server
|
||||||
|
.from("products")
|
||||||
|
.select("id, name, type, price")
|
||||||
|
.eq("brand_id", stop.brand_id)
|
||||||
|
.eq("active", true)
|
||||||
|
: { data: [] as { id: string; name: string; type: string; price: number }[] };
|
||||||
|
|
||||||
|
// 3. Assigned products (joined with product info)
|
||||||
|
const { data: productStops } = server
|
||||||
|
? await server
|
||||||
|
.from("product_stops")
|
||||||
|
.select("id, product_id, products(id, name, type, price)")
|
||||||
|
.eq("stop_id", stopId)
|
||||||
|
: { data: [] as AssignedProduct[] };
|
||||||
|
|
||||||
|
// 4. Brands for the brand switcher
|
||||||
|
const { data: brands } = server
|
||||||
|
? await server.from("brands").select("id, name, slug")
|
||||||
|
: { data: [] as { id: string; name: string; slug: string }[] };
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
stop,
|
||||||
|
allProducts: allProducts ?? [],
|
||||||
|
assignedProducts: (productStops ?? []) as AssignedProduct[],
|
||||||
|
brands: brands ?? [],
|
||||||
|
callerUid: adminUser.user_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
type Irrigator = {
|
type Irrigator = {
|
||||||
@@ -94,7 +95,7 @@ export async function updateWaterHeadgate(
|
|||||||
p_name: name,
|
p_name: name,
|
||||||
p_active: active,
|
p_active: active,
|
||||||
p_unit: unit ?? null,
|
p_unit: unit ?? null,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
p_high_threshold: highThreshold ?? null,
|
p_high_threshold: highThreshold ?? null,
|
||||||
p_low_threshold: lowThreshold ?? null,
|
p_low_threshold: lowThreshold ?? null,
|
||||||
}),
|
}),
|
||||||
@@ -186,7 +187,7 @@ export async function updateWaterIrrigator(
|
|||||||
p_active: active,
|
p_active: active,
|
||||||
p_lang: lang,
|
p_lang: lang,
|
||||||
p_role: role,
|
p_role: role,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -215,7 +216,7 @@ export async function resetWaterIrrigatorPin(
|
|||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_user_id: irrigatorId,
|
p_user_id: irrigatorId,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -242,7 +243,7 @@ export async function deleteWaterUser(userId: string): Promise<{ success: boolea
|
|||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_user_id: userId,
|
p_user_id: userId,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -269,7 +270,7 @@ export async function deleteWaterHeadgate(headgateId: string): Promise<{ success
|
|||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_headgate_id: headgateId,
|
p_headgate_id: headgateId,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -356,7 +357,7 @@ export async function regenerateHeadgateToken(headgateId: string): Promise<{ suc
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: adminUser.brand_id ?? null }),
|
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -392,7 +393,7 @@ export async function updateWaterEntry(
|
|||||||
p_measurement: measurement,
|
p_measurement: measurement,
|
||||||
p_notes: notes,
|
p_notes: notes,
|
||||||
p_unit: unit ?? null,
|
p_unit: unit ?? null,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -421,7 +422,7 @@ export async function deleteWaterEntry(
|
|||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_entry_id: entryId,
|
p_entry_id: entryId,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export async function registerWholesaleCustomer(params: {
|
export async function registerWholesaleCustomer(params: {
|
||||||
@@ -81,7 +82,9 @@ export async function approveWholesaleRegistration(
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
if (adminUser.role !== "platform_admin" && adminUser.brand_id !== brandId) {
|
try {
|
||||||
|
assertBrandAccess(adminUser, brandId);
|
||||||
|
} catch {
|
||||||
return { success: false, error: "Not authorized to operate on this brand" };
|
return { success: false, error: "Not authorized to operate on this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-27
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export type WholesaleOrder = {
|
export type WholesaleOrder = {
|
||||||
@@ -129,15 +130,16 @@ export type WholesaleDashboardStats = {
|
|||||||
* platform_admin → null (means "all brands" — passes to RPC unchanged)
|
* platform_admin → null (means "all brands" — passes to RPC unchanged)
|
||||||
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
|
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
|
||||||
* store_employee → their own brand_id
|
* store_employee → their own brand_id
|
||||||
|
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
|
||||||
* unauthenticated → null (actions should already bail out earlier)
|
* unauthenticated → null (actions should already bail out earlier)
|
||||||
*
|
*
|
||||||
* This prevents brand_admin from seeing or modifying another brand's data
|
* This prevents brand_admin from seeing or modifying another brand's data
|
||||||
* even if they manually pass a different brandId to the action.
|
* even if they manually pass a different brandId to the action.
|
||||||
*/
|
*/
|
||||||
function resolveBrandId(
|
async function resolveBrandId(
|
||||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||||
requestedBrandId?: string
|
requestedBrandId?: string
|
||||||
): string | null {
|
): Promise<string | null> {
|
||||||
if (!adminUser) return null;
|
if (!adminUser) return null;
|
||||||
|
|
||||||
if (adminUser.role === "platform_admin") {
|
if (adminUser.role === "platform_admin") {
|
||||||
@@ -145,15 +147,15 @@ function resolveBrandId(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// brand_admin and store_employee are scoped to their own brand
|
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||||
const userBrand = adminUser.brand_id ?? null;
|
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||||
|
|
||||||
if (requestedBrandId && requestedBrandId !== userBrand) {
|
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||||
// Brand admin trying to operate on another brand's data — block it
|
// Brand admin trying to operate on another brand's data — block it
|
||||||
return null; // caller should check and return unauthorized
|
return null; // caller should check and return unauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
return userBrand;
|
return activeBrandId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,23 +163,24 @@ function resolveBrandId(
|
|||||||
* if a brand_admin tries to operate outside their brand.
|
* if a brand_admin tries to operate outside their brand.
|
||||||
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
|
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
|
||||||
*/
|
*/
|
||||||
function enforceBrandScope(
|
async function enforceBrandScope(
|
||||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||||
requestedBrandId?: string
|
requestedBrandId?: string
|
||||||
): { brandId: string | null; error?: string } {
|
): Promise<{ brandId: string | null; error?: string }> {
|
||||||
if (!adminUser) return { brandId: null, error: "Not authenticated" };
|
if (!adminUser) return { brandId: null, error: "Not authenticated" };
|
||||||
|
|
||||||
if (adminUser.role === "platform_admin") {
|
if (adminUser.role === "platform_admin") {
|
||||||
return { brandId: null }; // unrestricted
|
return { brandId: null }; // unrestricted
|
||||||
}
|
}
|
||||||
|
|
||||||
const userBrand = adminUser.brand_id ?? null;
|
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||||
|
|
||||||
if (requestedBrandId && requestedBrandId !== userBrand) {
|
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||||
return { brandId: null, error: "Not authorized to operate on this brand" };
|
return { brandId: null, error: "Not authorized to operate on this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { brandId: userBrand };
|
return { brandId: activeBrandId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Orders ──────────────────────────────────────────────────────────────────
|
// ── Orders ──────────────────────────────────────────────────────────────────
|
||||||
@@ -185,7 +188,7 @@ function enforceBrandScope(
|
|||||||
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return [];
|
if (!adminUser) return [];
|
||||||
const bid = resolveBrandId(adminUser, brandId);
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
@@ -209,7 +212,7 @@ export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrd
|
|||||||
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return [];
|
if (!adminUser) return [];
|
||||||
const bid = resolveBrandId(adminUser, brandId);
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
@@ -256,7 +259,7 @@ export async function markWholesaleOrderFulfilled(orderId: string, brandId?: str
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { brandId: resolved, error } = enforceBrandScope(adminUser, brandId);
|
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -305,7 +308,7 @@ export async function updateWholesaleOrderStatus(
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { error } = enforceBrandScope(adminUser, brandId);
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -332,7 +335,7 @@ export async function deleteWholesaleOrder(orderId: string, brandId?: string): P
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { error } = enforceBrandScope(adminUser, brandId);
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -359,7 +362,7 @@ export async function deleteWholesaleCustomer(customerId: string, brandId?: stri
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { error } = enforceBrandScope(adminUser, brandId);
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -388,7 +391,7 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { error } = enforceBrandScope(adminUser, brandId);
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -417,7 +420,7 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
|
|||||||
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
|
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return [];
|
if (!adminUser) return [];
|
||||||
const bid = resolveBrandId(adminUser, brandId);
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
@@ -458,7 +461,7 @@ export async function saveWholesaleCustomer(params: {
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { error } = enforceBrandScope(adminUser, params.brandId);
|
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -501,7 +504,7 @@ export async function saveWholesaleCustomer(params: {
|
|||||||
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
|
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return [];
|
if (!adminUser) return [];
|
||||||
const bid = resolveBrandId(adminUser, brandId);
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
@@ -545,7 +548,7 @@ export async function saveWholesaleProduct(params: {
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { error } = enforceBrandScope(adminUser, params.brandId);
|
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -591,7 +594,10 @@ export async function saveWholesaleProduct(params: {
|
|||||||
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
|
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return null;
|
if (!adminUser) return null;
|
||||||
const bid = brandId ?? adminUser.brand_id ?? null;
|
const bid = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!bid && adminUser.role !== "platform_admin") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
@@ -700,7 +706,7 @@ export async function recordWholesaleDeposit(
|
|||||||
p_method: method,
|
p_method: method,
|
||||||
p_reference: reference ?? null,
|
p_reference: reference ?? null,
|
||||||
p_recorded_by: adminUser.user_id,
|
p_recorded_by: adminUser.user_id,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: await getActiveBrandId(adminUser),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -710,7 +716,7 @@ export async function recordWholesaleDeposit(
|
|||||||
if (!data?.success) return { success: false, error: data?.error ?? "Failed to record deposit" };
|
if (!data?.success) return { success: false, error: data?.error ?? "Failed to record deposit" };
|
||||||
|
|
||||||
// Fire webhook — fire-and-forget
|
// Fire webhook — fire-and-forget
|
||||||
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, adminUser.brand_id ?? undefined).catch(() => {});
|
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, (await getActiveBrandId(adminUser)) ?? undefined).catch(() => {});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -753,7 +759,7 @@ export async function bulkFulfillWholesaleOrders(
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_order_ids: orderIds,
|
p_order_ids: orderIds,
|
||||||
p_by: adminUser.user_id,
|
p_by: adminUser.user_id,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: await getActiveBrandId(adminUser),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -791,7 +797,7 @@ export async function bulkRecordWholesaleDeposit(
|
|||||||
p_method: method,
|
p_method: method,
|
||||||
p_reference: reference ?? null,
|
p_reference: reference ?? null,
|
||||||
p_recorded_by: adminUser.user_id,
|
p_recorded_by: adminUser.user_id,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: await getActiveBrandId(adminUser),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export default async function DebugAuthPage() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
|
|
||||||
const rcUid = cookieStore.get("rc_uid")?.value;
|
|
||||||
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
|
|
||||||
|
|
||||||
let adminUsersStatus = "not_tried";
|
|
||||||
let adminUsersResult: string | null = null;
|
|
||||||
|
|
||||||
if (rcAuthUid) {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
if (supabaseUrl && serviceKey) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
|
|
||||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
adminUsersStatus = String(res.status);
|
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
adminUsersResult = JSON.stringify(data);
|
|
||||||
} catch (e) {
|
|
||||||
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adminUsersStatus = "missing_env_vars";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adminUsersStatus = "no_rc_auth_uid_cookie";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold text-emerald-400 mb-6">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Key Cookies</h2>
|
|
||||||
<p>rc_auth_uid: <span className={rcAuthUid ? "text-emerald-400" : "text-red-400"}>{rcAuthUid ? `${rcAuthUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
|
||||||
<p>rc_uid: <span className={rcUid ? "text-emerald-400" : "text-red-400"}>{rcUid ? `${rcUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
|
||||||
<p>rc_access_token: <span className={rcAccessToken ? "text-yellow-400" : "text-red-400"}>{rcAccessToken ? "PRESENT" : "NOT SET"}</span></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Admin Users Lookup</h2>
|
|
||||||
<p>Status: <span className="text-lg font-mono">{adminUsersStatus}</span></p>
|
|
||||||
<p>Result: <pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto mt-2">{adminUsersResult || "(none)"}</pre></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import AdminSidebar from "@/components/admin/AdminSidebar";
|
import AdminSidebar from "@/components/admin/AdminSidebar";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { listBrandsForAdmin } from "@/actions/brands";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import "@/styles/admin-design-system.css";
|
import "@/styles/admin-design-system.css";
|
||||||
import { ToastProvider } from "@/components/admin/Toast";
|
import { ToastProvider } from "@/components/admin/Toast";
|
||||||
import { ToastContainer } from "@/components/admin/ToastContainer";
|
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
|
// Toast provider wrapper component
|
||||||
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
|
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -57,9 +64,34 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
redirect("/change-password");
|
redirect("/change-password");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the active brand (URL > cookie > legacy > first of brand_ids).
|
||||||
|
// Wrapped in try/catch so a transient brand-resolution failure can't
|
||||||
|
// crash the whole admin shell — we fall back to null (no active brand).
|
||||||
|
let activeBrandId: string | null = null;
|
||||||
|
try {
|
||||||
|
activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/layout] getActiveBrandId failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch accessible brands for the sidebar BrandSelector. Wrapped in
|
||||||
|
// try/catch so the sidebar renders empty rather than crashing the page
|
||||||
|
// if the brands query fails.
|
||||||
|
let brands: Awaited<ReturnType<typeof listBrandsForAdmin>> = [];
|
||||||
|
try {
|
||||||
|
brands = await listBrandsForAdmin();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/layout] listBrandsForAdmin failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProviderWrapper>
|
<ToastProviderWrapper>
|
||||||
<AdminSidebar userRole={adminUser.role} />
|
<AdminSidebar
|
||||||
|
userRole={adminUser.role}
|
||||||
|
brandIds={adminUser.brand_ids}
|
||||||
|
activeBrandId={activeBrandId}
|
||||||
|
brands={brands}
|
||||||
|
/>
|
||||||
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
import { AdminUserRow } from "@/actions/admin/users";
|
import { AdminUserRow } from "@/actions/admin/users";
|
||||||
import { logUserActivity } from "@/actions/admin/audit";
|
|
||||||
|
|
||||||
type ProfilePageProps = {
|
type ProfilePageProps = {
|
||||||
currentUser: AdminUserRow;
|
currentUser: AdminUserRow;
|
||||||
@@ -21,53 +19,24 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
|||||||
const [newEmail, setNewEmail] = useState("");
|
const [newEmail, setNewEmail] = useState("");
|
||||||
const [emailError, setEmailError] = useState<string | null>(null);
|
const [emailError, setEmailError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Profile / email mutations used to call Supabase directly. With the
|
||||||
|
// platform moved off Supabase entirely, those handlers are stubbed out
|
||||||
|
// — the page remains read-only until a server-action equivalent ships.
|
||||||
|
// See the final YOLO report for the broader Supabase → pg data-fetch
|
||||||
|
// migration that covers the rest of the admin pages.
|
||||||
|
|
||||||
async function handleSaveProfile(e: React.FormEvent) {
|
async function handleSaveProfile(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError("Profile editing is temporarily unavailable. Contact a platform admin.");
|
||||||
try {
|
setSaving(false);
|
||||||
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({
|
|
||||||
user_id: currentUser.user_id,
|
|
||||||
activity_type: "profile_update",
|
|
||||||
details: { fields: ["display_name", "phone_number"] },
|
|
||||||
});
|
|
||||||
setEditing(false);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEmailChange(e: React.FormEvent) {
|
async function handleEmailChange(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setChangingEmail(true);
|
setChangingEmail(true);
|
||||||
setEmailError(null);
|
setEmailError("Email changes are temporarily unavailable. Contact a platform admin.");
|
||||||
try {
|
setChangingEmail(false);
|
||||||
const { error: updateError } = await supabase.auth.updateUser({
|
|
||||||
email: newEmail,
|
|
||||||
});
|
|
||||||
if (updateError) {
|
|
||||||
setEmailError(updateError.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await logUserActivity({
|
|
||||||
user_id: currentUser.user_id,
|
|
||||||
activity_type: "email_change",
|
|
||||||
details: { new_email: newEmail },
|
|
||||||
});
|
|
||||||
setEmailChangeSent(true);
|
|
||||||
setChangingEmail(false);
|
|
||||||
} finally {
|
|
||||||
setChangingEmail(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
|
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { getAdminOrders } from "@/actions/orders";
|
import { getAdminOrders } from "@/actions/orders";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import { PageHeader } from "@/components/admin/design-system";
|
import { PageHeader } from "@/components/admin/design-system";
|
||||||
@@ -17,13 +18,19 @@ export default async function AdminOrdersPage() {
|
|||||||
redirect("/admin/pickup");
|
redirect("/admin/pickup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
// Platform admin can browse all brands' orders; everyone else must have a brand
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return <AdminAccessDenied message="You don't have access to any brand." />;
|
||||||
|
}
|
||||||
|
|
||||||
const { orders, stops } = await getAdminOrders();
|
const { orders, stops } = await getAdminOrders();
|
||||||
|
|
||||||
const brandStops = adminUser?.brand_id
|
const brandStops = activeBrandId
|
||||||
? stops.filter((s) => s.brand_id === adminUser.brand_id)
|
? stops.filter((s) => s.brand_id === activeBrandId)
|
||||||
: stops;
|
: stops;
|
||||||
|
|
||||||
const brandOrders = adminUser?.brand_id
|
const brandOrders = activeBrandId
|
||||||
? orders.filter(
|
? orders.filter(
|
||||||
(o) =>
|
(o) =>
|
||||||
o.stops && brandStops.some((s) => s.id === o.stop_id)
|
o.stops && brandStops.some((s) => s.id === o.stop_id)
|
||||||
@@ -41,8 +48,8 @@ export default async function AdminOrdersPage() {
|
|||||||
.order("name")
|
.order("name")
|
||||||
.limit(200);
|
.limit(200);
|
||||||
|
|
||||||
if (adminUser?.brand_id) {
|
if (activeBrandId) {
|
||||||
prodQuery = prodQuery.eq("brand_id", adminUser.brand_id);
|
prodQuery = prodQuery.eq("brand_id", activeBrandId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: prods } = await prodQuery;
|
const { data: prods } = await prodQuery;
|
||||||
@@ -80,7 +87,7 @@ export default async function AdminOrdersPage() {
|
|||||||
initialOrders={brandOrders}
|
initialOrders={brandOrders}
|
||||||
initialStops={brandStops}
|
initialStops={brandStops}
|
||||||
initialProducts={brandProducts}
|
initialProducts={brandProducts}
|
||||||
brandId={adminUser?.brand_id ?? null}
|
brandId={activeBrandId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+18
-13
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||||
import DashboardClient from "@/components/admin/DashboardClient";
|
import DashboardClient from "@/components/admin/DashboardClient";
|
||||||
@@ -23,20 +24,24 @@ export default async function AdminPage() {
|
|||||||
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
|
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
|
||||||
let brandDisplayName = "Admin";
|
let brandDisplayName = "Admin";
|
||||||
|
|
||||||
// For platform_admin in dev mode, adminUser.brand_id is null. To keep
|
// Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id
|
||||||
// the dashboard's "Active Products" stat in sync with the billing page,
|
// > first of brand_ids). For platform_admin in dev mode this is null, so we
|
||||||
// we need to pick a brand and use the same getBillingOverview action
|
// fall back to the first brand in the brands table to keep the dashboard's
|
||||||
// the billing page does. Otherwise the dashboard falls back to default
|
// "Active Products" stat in sync with the billing page. Wrapped in try/catch
|
||||||
// (0/0/0) usage values and contradicts the billing page's "Products 1/25".
|
// so a transient DB/network failure can't crash the whole admin page.
|
||||||
let dashboardBrandId: string | null = adminUser?.brand_id ?? null;
|
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
|
||||||
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
||||||
const { data: firstBrand } = await supabase
|
try {
|
||||||
.from("brands")
|
const { data: firstBrand } = await supabase
|
||||||
.select("id")
|
.from("brands")
|
||||||
.limit(1)
|
.select("id")
|
||||||
.single();
|
.limit(1)
|
||||||
if (firstBrand?.id) {
|
.single();
|
||||||
dashboardBrandId = firstBrand.id;
|
if (firstBrand?.id) {
|
||||||
|
dashboardBrandId = firstBrand.id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/page] supabase brands lookup failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import ProductsClient from "@/components/admin/ProductsClient";
|
import ProductsClient from "@/components/admin/ProductsClient";
|
||||||
|
import { getBrands } from "@/actions/admin/users";
|
||||||
|
|
||||||
// Icon for page header
|
// Icon for page header
|
||||||
const PackageIcon = () => (
|
const PackageIcon = () => (
|
||||||
@@ -29,7 +31,16 @@ export default async function AdminProductsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const brandId = adminUser.brand_id;
|
const activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
const brandId = activeBrandId;
|
||||||
|
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||||
|
|
||||||
|
// Platform admins need a brand picker for new products
|
||||||
|
let brands: { id: string; name: string }[] = [];
|
||||||
|
if (isPlatformAdmin) {
|
||||||
|
const result = await getBrands();
|
||||||
|
brands = result.brands ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from("products")
|
.from("products")
|
||||||
@@ -48,8 +59,8 @@ export default async function AdminProductsPage() {
|
|||||||
.is("deleted_at", null)
|
.is("deleted_at", null)
|
||||||
.order("name");
|
.order("name");
|
||||||
|
|
||||||
if (adminUser.brand_id) {
|
if (brandId) {
|
||||||
query = query.eq("brand_id", adminUser.brand_id);
|
query = query.eq("brand_id", brandId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: products, error } = await query;
|
const { data: products, error } = await query;
|
||||||
@@ -69,7 +80,12 @@ export default async function AdminProductsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||||
<ProductsClient products={products ?? []} brandId={brandId ?? undefined} />
|
<ProductsClient
|
||||||
|
products={products ?? []}
|
||||||
|
brandId={brandId ?? undefined}
|
||||||
|
brands={brands}
|
||||||
|
isPlatformAdmin={isPlatformAdmin}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import ReportsDashboard from "@/components/admin/ReportsDashboard";
|
import ReportsDashboard from "@/components/admin/ReportsDashboard";
|
||||||
import { PageHeader } from "@/components/admin/design-system";
|
import { PageHeader } from "@/components/admin/design-system";
|
||||||
@@ -19,7 +20,7 @@ export default async function ReportsPage() {
|
|||||||
|
|
||||||
const initialBrandId = isPlatformAdmin
|
const initialBrandId = isPlatformAdmin
|
||||||
? null
|
? null
|
||||||
: adminUser.brand_id ?? null;
|
: await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import BillingClientPage from "./BillingClientPage";
|
import BillingClientPage from "./BillingClientPage";
|
||||||
@@ -13,7 +14,11 @@ export default async function BillingPage({ params }: Props) {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return <AdminAccessDenied />;
|
if (!adminUser) return <AdminAccessDenied />;
|
||||||
|
|
||||||
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
|
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return <AdminAccessDenied message="You don't have access to that brand." />;
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? "";
|
||||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||||
|
|
||||||
let resolvedBrandId = effectiveBrandId;
|
let resolvedBrandId = effectiveBrandId;
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { getPaymentSettings } from "@/actions/payments";
|
|||||||
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||||
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
|
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
|
||||||
|
|
||||||
|
// Uses cookies() via getAdminUser — must be dynamic to avoid the
|
||||||
|
// "couldn't be rendered statically" build error.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function SquareSyncSettingsPage() {
|
export default async function SquareSyncSettingsPage() {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) redirect("/login");
|
if (!adminUser) redirect("/login");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import StopTableClient from "@/components/admin/StopTableClient";
|
import StopsDashboardClient from "@/components/admin/stops/StopsDashboardClient";
|
||||||
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
|
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
@@ -90,9 +90,7 @@ export default async function AdminStopsPage() {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||||
<div className="overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
|
<StopsDashboardClient stops={stops ?? []} />
|
||||||
<StopTableClient stops={stops ?? []} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import TaxDashboard from "@/components/admin/TaxDashboard";
|
import TaxDashboard from "@/components/admin/TaxDashboard";
|
||||||
@@ -15,7 +16,11 @@ export default async function TaxesPage({ params }: Props) {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return <AdminAccessDenied />;
|
if (!adminUser) return <AdminAccessDenied />;
|
||||||
|
|
||||||
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
|
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return <AdminAccessDenied message="You don't have access to that brand." />;
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? "";
|
||||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||||
|
|
||||||
let resolvedBrandId = effectiveBrandId;
|
let resolvedBrandId = effectiveBrandId;
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function TestAuthPage() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
|
|
||||||
let adminUser = null;
|
|
||||||
let error: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
adminUser = await getAdminUser();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error = e instanceof Error ? e.message : String(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">All Cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_auth_uid</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_auth_uid")
|
|
||||||
? <span className="text-emerald-400">SET — {allCookies.find(c => c.name === "rc_auth_uid")?.value}</span>
|
|
||||||
: <span className="text-red-400">NOT SET</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_access_token")
|
|
||||||
? <span className="text-yellow-400">SET (not needed)</span>
|
|
||||||
: <span className="text-zinc-500">NOT SET (OK)</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
|
|
||||||
{error ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-red-400 font-bold">ERROR</p>
|
|
||||||
<pre className="bg-red-950/50 p-4 rounded-xl text-sm mt-2">{error}</pre>
|
|
||||||
</div>
|
|
||||||
) : adminUser ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-emerald-400 font-bold">AUTHENTICATED</p>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-sm mt-2 overflow-auto">
|
|
||||||
{JSON.stringify({
|
|
||||||
id: adminUser.id,
|
|
||||||
user_id: adminUser.user_id,
|
|
||||||
role: adminUser.role,
|
|
||||||
brand_id: adminUser.brand_id,
|
|
||||||
active: adminUser.active,
|
|
||||||
}, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-red-400">NOT AUTHENTICATED — null returned</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-4 border-t border-zinc-800">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Quick Actions</h2>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<a href="/admin" className="px-4 py-2 bg-emerald-600 text-white rounded-xl text-sm font-bold hover:bg-emerald-500">
|
|
||||||
Go to Admin
|
|
||||||
</a>
|
|
||||||
<form action="/api/logout" method="POST">
|
|
||||||
<button type="submit" className="px-4 py-2 bg-zinc-800 text-white rounded-xl text-sm font-bold hover:bg-zinc-700">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function TestPage() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
let adminUser = null;
|
|
||||||
let adminUserError: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
adminUser = await getAdminUser();
|
|
||||||
} catch (e: any) {
|
|
||||||
adminUserError = e?.message ?? String(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Server cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}...`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token present?</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_access_token")
|
|
||||||
? <span className="text-emerald-400">YES — {allCookies.find(c => c.name === "rc_access_token")?.value.length} chars</span>
|
|
||||||
: <span className="text-red-400">NO</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
|
|
||||||
{adminUserError ? (
|
|
||||||
<p className="text-red-400">ERROR: {adminUserError}</p>
|
|
||||||
) : (
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-sm overflow-auto">
|
|
||||||
{JSON.stringify(adminUser, null, 2)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import WholesaleClient from "./WholesaleClient";
|
import WholesaleClient from "./WholesaleClient";
|
||||||
|
|
||||||
export default async function WholesalePage() {
|
export default async function WholesalePage() {
|
||||||
@@ -11,10 +13,10 @@ export default async function WholesalePage() {
|
|||||||
devSession === "store_employee";
|
devSession === "store_employee";
|
||||||
|
|
||||||
if (!isDevMode) {
|
if (!isDevMode) {
|
||||||
const { getAdminUser } = await import("@/lib/admin-permissions");
|
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) redirect("/login");
|
if (!adminUser) redirect("/login");
|
||||||
return <WholesaleClient brandId={adminUser.brand_id ?? ""} />;
|
const activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
return <WholesaleClient brandId={activeBrandId ?? ""} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dev mode: platform_admin sees all brands, use first brand as default
|
// Dev mode: platform_admin sees all brands, use first brand as default
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Auth.js v5 route handler — re-exports the GET/POST handlers from src/lib/auth.ts
|
||||||
|
// Mounted at /api/auth/* (signin, signout, callback, session, csrf, providers, etc.)
|
||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const uid =
|
|
||||||
cookieStore.get("rc_auth_uid")?.value ??
|
|
||||||
cookieStore.get("rc_uid")?.value ??
|
|
||||||
null;
|
|
||||||
return NextResponse.json({ uid });
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? "";
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
|
|
||||||
|
|
||||||
if (!serviceKey || !supabaseUrl) {
|
|
||||||
return NextResponse.json({ error: "Missing env vars", serviceKey: !!serviceKey, supabaseUrl: !!supabaseUrl }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 1: just a simple health endpoint that doesn't require the key
|
|
||||||
let healthResult = null;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/`, {
|
|
||||||
headers: { apikey: serviceKey },
|
|
||||||
});
|
|
||||||
healthResult = { status: res.status, ok: res.ok };
|
|
||||||
} catch (e: any) {
|
|
||||||
healthResult = { error: e?.message };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 2: try admin_users with POST (bypasses RLS select policies)
|
|
||||||
let adminResult = null;
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/admin_users?select=id,user_id,role,email,display_name&limit=5`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
apikey: serviceKey,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Prefer: "return=representation",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const body = await res.text();
|
|
||||||
let parsed = null;
|
|
||||||
try { parsed = JSON.parse(body); } catch { parsed = body; }
|
|
||||||
adminResult = { status: res.status, body: parsed };
|
|
||||||
} catch (e: any) {
|
|
||||||
adminResult = { error: e?.message };
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ healthResult, adminResult, serviceKeyLen: serviceKey.length });
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
|
|
||||||
const rcUid = cookieStore.get("rc_uid")?.value;
|
|
||||||
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
|
|
||||||
|
|
||||||
let adminUsersStatus = "not_tried";
|
|
||||||
let adminUsersResult: string | null = null;
|
|
||||||
|
|
||||||
if (rcAuthUid) {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
if (supabaseUrl && serviceKey) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
|
|
||||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
adminUsersStatus = String(res.status);
|
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
adminUsersResult = JSON.stringify(data);
|
|
||||||
} catch (e) {
|
|
||||||
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adminUsersStatus = "missing_env_vars";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adminUsersStatus = "no_rc_auth_uid_cookie";
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
cookies: {
|
|
||||||
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 10)}...` : null,
|
|
||||||
rc_uid: rcUid ? `${rcUid.slice(0, 10)}...` : null,
|
|
||||||
rc_access_token: rcAccessToken ? "present" : null,
|
|
||||||
all_cookie_names: allCookies.map(c => c.name),
|
|
||||||
},
|
|
||||||
admin_users: {
|
|
||||||
status: adminUsersStatus,
|
|
||||||
result: adminUsersResult,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
|
|
||||||
const rcUid = cookieStore.get("rc_uid")?.value;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
received_cookies: allCookies.map(c => ({ name: c.name, value_len: c.value.length })),
|
|
||||||
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 8)}...` : "MISSING",
|
|
||||||
rc_uid: rcUid ? `${rcUid.slice(0, 8)}...` : "MISSING",
|
|
||||||
raw_rc_auth_uid: rcAuthUid ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
const nodeEnv = process.env.NODE_ENV;
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
NODE_ENV: nodeEnv,
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL: url ? `${url.substring(0, 40)}... (SET)` : "MISSING",
|
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: key ? `${key.substring(0, 20)}... (SET)` : "MISSING",
|
|
||||||
SUPABASE_SERVICE_ROLE_KEY: serviceKey ? `${serviceKey.substring(0, 20)}... (SET)` : "MISSING",
|
|
||||||
supabaseClientCanCreate: false as boolean,
|
|
||||||
error: null as string | null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (url && key) {
|
|
||||||
try {
|
|
||||||
const { createClient } = await import("@supabase/supabase-js");
|
|
||||||
const client = createClient(url, key);
|
|
||||||
result.supabaseClientCanCreate = true;
|
|
||||||
} catch (e: any) {
|
|
||||||
result.error = e?.message ?? String(e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.error = "Missing env vars";
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(result, { status: 200 });
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
// Test the REST call directly from this endpoint
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
|
||||||
const uid = "c023efb3-e3ef-4156-bed6-d17b92ea8aca";
|
|
||||||
|
|
||||||
let restResult = null;
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
|
|
||||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
restResult = { status: res.status, data, keyLen: serviceKey.length };
|
|
||||||
} catch (e: any) {
|
|
||||||
restResult = { error: e?.message };
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
return NextResponse.json({
|
|
||||||
adminUser: adminUser ? {
|
|
||||||
id: adminUser.id,
|
|
||||||
user_id: adminUser.user_id,
|
|
||||||
role: adminUser.role,
|
|
||||||
brand_id: adminUser.brand_id,
|
|
||||||
active: adminUser.active,
|
|
||||||
} : null,
|
|
||||||
restResult,
|
|
||||||
supabaseUrl: supabaseUrl ? "SET" : "MISSING",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
return NextResponse.json({
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
deployment: "debug-hello",
|
|
||||||
msg: "hello from latest build"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
|
|
||||||
|
|
||||||
if (!uid) {
|
|
||||||
return NextResponse.json({ error: "No uid cookie found" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl || !serviceKey) {
|
|
||||||
return NextResponse.json({
|
|
||||||
uid,
|
|
||||||
error: "Missing env vars",
|
|
||||||
supabaseUrl: supabaseUrl ?? "MISSING",
|
|
||||||
serviceKeyPresent: !!serviceKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exact same lookup as getAdminUser
|
|
||||||
const lookupRes = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
|
|
||||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
|
|
||||||
let adminUsers: unknown[] = [];
|
|
||||||
let lookupOk = lookupRes.ok;
|
|
||||||
let lookupStatus = lookupRes.status;
|
|
||||||
let lookupData: unknown = null;
|
|
||||||
|
|
||||||
if (lookupRes.ok) {
|
|
||||||
lookupData = await lookupRes.json().catch(() => []);
|
|
||||||
adminUsers = Array.isArray(lookupData) ? lookupData : [];
|
|
||||||
} else {
|
|
||||||
lookupData = await lookupRes.text().catch(() => "unknown error");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminUsers.length > 0) {
|
|
||||||
return NextResponse.json({
|
|
||||||
uid,
|
|
||||||
result: "found",
|
|
||||||
adminUser: adminUsers[0],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try auto-create
|
|
||||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
||||||
if (!UUID_REGEX.test(uid)) {
|
|
||||||
return NextResponse.json({ uid, result: "invalid_uuid" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const postRes = await fetch(`${supabaseUrl}/rest/v1/admin_users`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
user_id: uid, role: "platform_admin", brand_id: null, 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
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
uid,
|
|
||||||
result: "auto_created",
|
|
||||||
lookupOk,
|
|
||||||
lookupStatus,
|
|
||||||
adminUsersFound: adminUsers.length,
|
|
||||||
postStatus: postRes.status,
|
|
||||||
postOk: postRes.ok,
|
|
||||||
postData: postRes.ok ? await postRes.json().catch(() => null) : await postRes.text().catch(() => null),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
|
|
||||||
response.cookies.set("dev_session", "platform_admin", {
|
|
||||||
path: "/",
|
|
||||||
sameSite: "lax",
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
|
|
||||||
response.cookies.set("dev_session", "platform_admin", {
|
|
||||||
path: "/",
|
|
||||||
sameSite: "lax",
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
|
||||||
const DEV_ROLES = ["platform_admin", "brand_admin", "store_employee"];
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const role = url.searchParams.get("role") ?? "platform_admin";
|
|
||||||
const safeRole = DEV_ROLES.includes(role) ? role : "platform_admin";
|
|
||||||
|
|
||||||
const origin = url.origin;
|
|
||||||
|
|
||||||
const response = NextResponse.redirect(new URL("/admin", origin));
|
|
||||||
|
|
||||||
const cookieOptions = {
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
sameSite: "lax" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
response.cookies.set("dev_session", safeRole, {
|
|
||||||
...cookieOptions,
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
response.cookies.set("rc_auth_uid", DEV_ADMIN_UID, {
|
|
||||||
...cookieOptions,
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user