Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d892b3f64f | |||
| e7ac495831 | |||
| c9b1c49f53 | |||
| cbc8958b55 | |||
| be226d3430 | |||
| b433c79677 | |||
| a266203c07 | |||
| 3f4145e800 | |||
| 553bfed070 | |||
| 452eef7606 | |||
| c20538ef9f | |||
| 66d8cdf9b2 | |||
| e41ce98b74 | |||
| 85db68ed70 | |||
| 4eb6b173e0 | |||
| 2635c0a99d | |||
| 3a9c6e3934 | |||
| d9dee2c926 | |||
| a15f2b7dcf | |||
| 2565c18cdd | |||
| e6a97ba9ab |
+29
-76
@@ -1,81 +1,34 @@
|
|||||||
# ============================================================================
|
# --- Self-hosted Postgres (Docker) ---
|
||||||
# Route Commerce — Environment variables
|
POSTGRES_USER=routecommerce
|
||||||
# ============================================================================
|
POSTGRES_PASSWORD=routecommerce_dev_password
|
||||||
# Copy to `.env.local` and fill in real values for local development.
|
POSTGRES_DB=route_commerce
|
||||||
# Production: set these in your hosting dashboard (Vercel / Netlify / etc.).
|
# Used by the app and push-migrations.js to talk to the local DB
|
||||||
# ============================================================================
|
DATABASE_URL=postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce?schema=public
|
||||||
|
|
||||||
# ── App ─────────────────────────────────────────────────────────────────────
|
# --- PostgREST (REST API) ---
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:4000
|
# Server actions call `${NEXT_PUBLIC_API_URL}/rest/v1/rpc/...`
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:4000
|
# Point that URL at the local PostgREST container. Anon key can be anything
|
||||||
|
# non-empty since we handle auth at the app layer (Better Auth + dev_session).
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||||
|
NEXT_PUBLIC_API_ANON_KEY=local-postgrest-anon-key
|
||||||
|
POSTGREST_SERVICE_KEY=local-postgrest-service-key
|
||||||
|
|
||||||
# ── Database (Neon Postgres, direct pg driver) ─────────────────────────────
|
# --- MinIO (S3-compatible object storage — replaces Supabase Storage) ---
|
||||||
# Single connection string used by the pg Pool in src/lib/auth.ts and the
|
MINIO_ROOT_USER=routecommerce
|
||||||
# admin-permissions / data-service layer. Format:
|
MINIO_ROOT_PASSWORD=miniochangeme123
|
||||||
# postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require
|
STORAGE_ENDPOINT=http://localhost:9000
|
||||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce
|
STORAGE_REGION=us-east-1
|
||||||
|
STORAGE_ACCESS_KEY=routecommerce
|
||||||
|
STORAGE_SECRET_KEY=miniochangeme123
|
||||||
|
STORAGE_BUCKET_PREFIX=
|
||||||
|
# Public base URL for image rendering in <img src=...>
|
||||||
|
NEXT_PUBLIC_STORAGE_BASE_URL=http://localhost:9000
|
||||||
|
|
||||||
# ── Neon Auth (Better Auth) ───────────────────────────────────────────────
|
# --- Better Auth ---
|
||||||
# Get these from: neonctl neon-auth status
|
BETTER_AUTH_SECRET=REPLACE_ME_WITH_OPENSSL_RAND_HEX_32
|
||||||
# Base URL: "Auth Base URL" field
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
# Cookie secret: openssl rand -base64 32 (min 32 chars)
|
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
|
||||||
NEON_AUTH_BASE_URL=https://your-branch.neonauth.region.aws.neon.tech/neondb/auth
|
|
||||||
NEON_AUTH_COOKIE_SECRET=replace-me-with-a-32-char-secret
|
|
||||||
|
|
||||||
# ── Stripe ─────────────────────────────────────────────────────────────────
|
# --- App secrets ---
|
||||||
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_API_KEY=
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
MINIMAX_BASE_URL=
|
||||||
|
|
||||||
# ── Square (optional) ─────────────────────────────────────────────────────
|
|
||||||
SQUARE_APP_SECRET=
|
|
||||||
SQUARE_ENVIRONMENT=sandbox
|
|
||||||
|
|
||||||
# ── MinIO / S3-compatible object storage ─────────────────────────────────────
|
|
||||||
# Endpoint: host:port only (no https://). Use MINIO_PUBLIC_URL for the public base.
|
|
||||||
MINIO_ENDPOINT=s3.crispygoat.com
|
|
||||||
MINIO_REGION=us-east-1
|
|
||||||
MINIO_USE_SSL=true
|
|
||||||
MINIO_ACCESS_KEY=routecommerce
|
|
||||||
MINIO_SECRET_KEY=miniochangeme123
|
|
||||||
# Override public URL if different from endpoint (e.g. Cloudflare CDN in front)
|
|
||||||
MINIO_PUBLIC_URL=https://s3.crispygoat.com
|
|
||||||
# Bucket names — create these in MinIO first (mc mb myminio/route-products, etc.)
|
|
||||||
MINIO_BUCKET_PRODUCTS=route-products
|
|
||||||
MINIO_BUCKET_BRAND_LOGOS=route-brand-logos
|
|
||||||
MINIO_BUCKET_WATER_LOGS=route-water-logs
|
|
||||||
|
|
||||||
# ── Cron / automation ───────────────────────────────────────────────────────
|
|
||||||
CRON_SECRET=replace-me-with-a-random-string
|
|
||||||
|
|
||||||
# ── Water Log ───────────────────────────────────────────────────────────────
|
|
||||||
# The Water Log module reuses the existing `DATABASE_URL` and (for photo
|
|
||||||
# uploads) the `MINIO_BUCKET_WATER_LOGS` bucket above. It also reuses the
|
|
||||||
# brand's Twilio / SMS config for high/low threshold alerts. The
|
|
||||||
# Tuxedo brand UUID is a public default used as a fallback for cold-start
|
|
||||||
# paths when the calling admin user is a platform_admin with brand_id=null.
|
|
||||||
# It does NOT need to be set as an env var — the value is hardcoded in
|
|
||||||
# the server actions — but you can override it with:
|
|
||||||
# TUXEDO_BRAND_ID=64294306-5f42-463d-a5e8-2ad6c81a96de
|
|
||||||
# See docs/water-log.md for the full module guide.
|
|
||||||
|
|||||||
+136
-155
@@ -4,7 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
@@ -16,187 +15,169 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Start Docker stack
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||||
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||||
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
|
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||||
|
run: |
|
||||||
|
APP_DIR=/home/tyler/route-commerce
|
||||||
|
mkdir -p $APP_DIR
|
||||||
|
cd $APP_DIR
|
||||||
|
[ -f .env ] || cp .env.example .env
|
||||||
|
# Append production secrets to .env (overriding .env.example defaults)
|
||||||
|
{
|
||||||
|
echo "POSTGRES_USER=${POSTGRES_USER}"
|
||||||
|
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||||
|
echo "POSTGRES_DB=${POSTGRES_DB}"
|
||||||
|
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
|
||||||
|
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
|
||||||
|
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}"
|
||||||
|
} >> .env
|
||||||
|
docker compose up -d db postgrest minio minio_init
|
||||||
|
# Wait for Postgres healthcheck
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then
|
||||||
|
echo "Postgres is ready"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Apply migrations
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||||
|
run: |
|
||||||
|
cd /home/tyler/route-commerce
|
||||||
|
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/migrations/000_preflight_supabase_compat.sql || true
|
||||||
|
[ -f supabase/captured_schema.sql ] && PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/captured_schema.sql || echo "captured_schema.sql not present, skipping"
|
||||||
|
for f in supabase/migrations/[0-9]*.sql; do
|
||||||
|
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -q -f "$f" || echo "FAIL: $f"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Run migrations
|
|
||||||
env:
|
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
node scripts/preflight-check.js
|
|
||||||
npm run migrate:one
|
|
||||||
node scripts/postflight-check.js
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
env:
|
env:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}
|
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
|
||||||
NEON_AUTH_BASE_URL: ${{ secrets.NEON_AUTH_BASE_URL }}
|
BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
|
||||||
NEON_AUTH_COOKIE_SECRET: ${{ secrets.NEON_AUTH_COOKIE_SECRET }}
|
NEXT_PUBLIC_BETTER_AUTH_URL: ${{ secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
|
||||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||||
AUTH_URL: ${{ secrets.AUTH_URL }}
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }}
|
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
|
||||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
|
||||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
|
||||||
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
|
||||||
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
|
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
|
||||||
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||||
STRIPE_PRICE_STARTER: ${{ secrets.STRIPE_PRICE_STARTER }}
|
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
|
||||||
STRIPE_PRICE_FARM: ${{ secrets.STRIPE_PRICE_FARM }}
|
|
||||||
STRIPE_PRICE_ENTERPRISE: ${{ secrets.STRIPE_PRICE_ENTERPRISE }}
|
|
||||||
STRIPE_PRICE_HARVEST_REACH: ${{ secrets.STRIPE_PRICE_HARVEST_REACH }}
|
|
||||||
STRIPE_PRICE_WHOLESALE_PORTAL: ${{ secrets.STRIPE_PRICE_WHOLESALE_PORTAL }}
|
|
||||||
STRIPE_PRICE_WATER_LOG: ${{ secrets.STRIPE_PRICE_WATER_LOG }}
|
|
||||||
STRIPE_PRICE_AI_TOOLS: ${{ secrets.STRIPE_PRICE_AI_TOOLS }}
|
|
||||||
STRIPE_PRICE_SQUARE_SYNC: ${{ secrets.STRIPE_PRICE_SQUARE_SYNC }}
|
|
||||||
STRIPE_PRICE_SMS_CAMPAIGNS: ${{ secrets.STRIPE_PRICE_SMS_CAMPAIGNS }}
|
|
||||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
|
||||||
MINIO_ENDPOINT: ${{ secrets.MINIO_ENDPOINT }}
|
|
||||||
MINIO_REGION: ${{ secrets.MINIO_REGION }}
|
|
||||||
MINIO_ACCESS_KEY: ${{ secrets.MINIO_ACCESS_KEY }}
|
|
||||||
MINIO_SECRET_KEY: ${{ secrets.MINIO_SECRET_KEY }}
|
|
||||||
MINIO_PUBLIC_URL: ${{ secrets.MINIO_PUBLIC_URL }}
|
|
||||||
MINIO_BUCKET_PRODUCTS: ${{ secrets.MINIO_BUCKET_PRODUCTS }}
|
|
||||||
MINIO_BUCKET_BRAND_LOGOS: ${{ secrets.MINIO_BUCKET_BRAND_LOGOS }}
|
|
||||||
MINIO_BUCKET_WATER_LOGS: ${{ secrets.MINIO_BUCKET_WATER_LOGS }}
|
|
||||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||||
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||||
|
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
NEON_AUTH_BASE_URL: ${{ secrets.NEON_AUTH_BASE_URL }}
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
NEON_AUTH_COOKIE_SECRET: ${{ secrets.NEON_AUTH_COOKIE_SECRET }}
|
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||||
AUTH_URL: ${{ secrets.AUTH_URL }}
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }}
|
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
|
||||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
|
||||||
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
NEXT_PUBLIC_BETTER_AUTH_URL: ${{ secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
|
||||||
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
|
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
|
||||||
|
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
|
||||||
|
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
|
||||||
|
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
|
||||||
|
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
|
||||||
|
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
|
||||||
|
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
|
||||||
|
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
|
||||||
|
PGRST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||||
STRIPE_PRICE_STARTER: ${{ secrets.STRIPE_PRICE_STARTER }}
|
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
|
||||||
STRIPE_PRICE_FARM: ${{ secrets.STRIPE_PRICE_FARM }}
|
|
||||||
STRIPE_PRICE_ENTERPRISE: ${{ secrets.STRIPE_PRICE_ENTERPRISE }}
|
|
||||||
STRIPE_PRICE_HARVEST_REACH: ${{ secrets.STRIPE_PRICE_HARVEST_REACH }}
|
|
||||||
STRIPE_PRICE_WHOLESALE_PORTAL: ${{ secrets.STRIPE_PRICE_WHOLESALE_PORTAL }}
|
|
||||||
STRIPE_PRICE_WATER_LOG: ${{ secrets.STRIPE_PRICE_WATER_LOG }}
|
|
||||||
STRIPE_PRICE_AI_TOOLS: ${{ secrets.STRIPE_PRICE_AI_TOOLS }}
|
|
||||||
STRIPE_PRICE_SQUARE_SYNC: ${{ secrets.STRIPE_PRICE_SQUARE_SYNC }}
|
|
||||||
STRIPE_PRICE_SMS_CAMPAIGNS: ${{ secrets.STRIPE_PRICE_SMS_CAMPAIGNS }}
|
|
||||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
|
||||||
MINIO_ENDPOINT: ${{ secrets.MINIO_ENDPOINT }}
|
|
||||||
MINIO_REGION: ${{ secrets.MINIO_REGION }}
|
|
||||||
MINIO_ACCESS_KEY: ${{ secrets.MINIO_ACCESS_KEY }}
|
|
||||||
MINIO_SECRET_KEY: ${{ secrets.MINIO_SECRET_KEY }}
|
|
||||||
MINIO_PUBLIC_URL: ${{ secrets.MINIO_PUBLIC_URL }}
|
|
||||||
MINIO_BUCKET_PRODUCTS: ${{ secrets.MINIO_BUCKET_PRODUCTS }}
|
|
||||||
MINIO_BUCKET_BRAND_LOGOS: ${{ secrets.MINIO_BUCKET_BRAND_LOGOS }}
|
|
||||||
MINIO_BUCKET_WATER_LOGS: ${{ secrets.MINIO_BUCKET_WATER_LOGS }}
|
|
||||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||||
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
||||||
SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }}
|
|
||||||
run: |
|
run: |
|
||||||
set -e
|
|
||||||
APP_DIR=/home/tyler/route-commerce
|
APP_DIR=/home/tyler/route-commerce
|
||||||
|
mkdir -p $APP_DIR
|
||||||
|
|
||||||
# Setup SSH key - write raw (no printf which can corrupt multi-line keys)
|
# Write env file from secrets (preserves existing .env for docker compose)
|
||||||
mkdir -p ~/.ssh
|
{
|
||||||
echo "$SERVER_SSH_KEY" > ~/.ssh/id_ed25519
|
printf "DATABASE_URL=%s\n" "$DATABASE_URL"
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
printf "POSTGRES_USER=%s\n" "$POSTGRES_USER"
|
||||||
|
printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD"
|
||||||
|
printf "POSTGRES_DB=%s\n" "$POSTGRES_DB"
|
||||||
|
printf "MINIO_ROOT_USER=%s\n" "$MINIO_ROOT_USER"
|
||||||
|
printf "MINIO_ROOT_PASSWORD=%s\n" "$MINIO_ROOT_PASSWORD"
|
||||||
|
printf "POSTGREST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
|
||||||
|
printf "BETTER_AUTH_SECRET=%s\n" "$BETTER_AUTH_SECRET"
|
||||||
|
printf "BETTER_AUTH_URL=%s\n" "$BETTER_AUTH_URL"
|
||||||
|
printf "NEXT_PUBLIC_BETTER_AUTH_URL=%s\n" "$NEXT_PUBLIC_BETTER_AUTH_URL"
|
||||||
|
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
|
||||||
|
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
|
||||||
|
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
|
||||||
|
printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY"
|
||||||
|
printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX"
|
||||||
|
printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL"
|
||||||
|
printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT"
|
||||||
|
printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI"
|
||||||
|
printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE"
|
||||||
|
printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
|
||||||
|
printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL"
|
||||||
|
printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY"
|
||||||
|
printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY"
|
||||||
|
printf "STRIPE_WEBHOOK_SECRET=%s\n" "$STRIPE_WEBHOOK_SECRET"
|
||||||
|
printf "STRIPE_PUBLISHABLE_KEY=%s\n" "$STRIPE_PUBLISHABLE_KEY"
|
||||||
|
printf "RESEND_API_KEY=%s\n" "$RESEND_API_KEY"
|
||||||
|
printf "RESEND_WEBHOOK_SECRET=%s\n" "$RESEND_WEBHOOK_SECRET"
|
||||||
|
printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY"
|
||||||
|
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL"
|
||||||
|
printf "FROM_EMAIL=%s\n" "$FROM_EMAIL"
|
||||||
|
} > $APP_DIR/.env.production
|
||||||
|
|
||||||
# Verify key was written correctly
|
# Copy build output and required files
|
||||||
if ! grep -q "PRIVATE KEY" ~/.ssh/id_ed25519; then
|
rsync -a --delete .next/ $APP_DIR/.next/
|
||||||
echo "ERROR: SSH key not found or malformed. Check SERVER_SSH_KEY secret."
|
rsync -a --delete public/ $APP_DIR/public/
|
||||||
cat ~/.ssh/id_ed25519 || echo "File is empty"
|
cp package.json $APP_DIR/
|
||||||
exit 1
|
cp docker-compose.yml $APP_DIR/
|
||||||
|
cp -r supabase/ $APP_DIR/
|
||||||
|
cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true
|
||||||
|
|
||||||
|
# Install production deps only
|
||||||
|
cd $APP_DIR
|
||||||
|
npm install --omit=dev
|
||||||
|
|
||||||
|
# Start or restart PM2 process
|
||||||
|
if pm2 describe route-commerce > /dev/null 2>&1; then
|
||||||
|
pm2 restart route-commerce
|
||||||
|
else
|
||||||
|
pm2 start npm --name route-commerce -- start -- -p 3100
|
||||||
|
pm2 save
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ssh-keyscan -H route.crispygoat.com >> ~/.ssh/known_hosts 2>/dev/null || true
|
|
||||||
|
|
||||||
# Test SSH connection with verbose output for debugging
|
|
||||||
echo "Testing SSH connection..."
|
|
||||||
ssh -o ConnectTimeout=15 -o StrictHostKeyChecking=no -o LogLevel=VERBOSE tyler@route.crispygoat.com "echo 'SSH OK' && hostname" 2>&1 || { echo "SSH FAILED"; exit 1; }
|
|
||||||
|
|
||||||
# Create app dir on server
|
|
||||||
ssh tyler@route.crispygoat.com "mkdir -p $APP_DIR/.next $APP_DIR/public"
|
|
||||||
|
|
||||||
# Write production env file
|
|
||||||
ENV_FILE=$(mktemp)
|
|
||||||
{
|
|
||||||
printf 'DATABASE_URL=%s\n' "$DATABASE_URL"
|
|
||||||
printf 'NEXT_PUBLIC_SITE_URL=%s\n' "$NEXT_PUBLIC_SITE_URL"
|
|
||||||
printf 'NEON_AUTH_BASE_URL=%s\n' "$NEON_AUTH_BASE_URL"
|
|
||||||
printf 'NEON_AUTH_COOKIE_SECRET=%s\n' "$NEON_AUTH_COOKIE_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 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=%s\n' "$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY"
|
|
||||||
printf 'STRIPE_SECRET_KEY=%s\n' "$STRIPE_SECRET_KEY"
|
|
||||||
printf 'STRIPE_WEBHOOK_SECRET=%s\n' "$STRIPE_WEBHOOK_SECRET"
|
|
||||||
printf 'STRIPE_PRICE_STARTER=%s\n' "$STRIPE_PRICE_STARTER"
|
|
||||||
printf 'STRIPE_PRICE_FARM=%s\n' "$STRIPE_PRICE_FARM"
|
|
||||||
printf 'STRIPE_PRICE_ENTERPRISE=%s\n' "$STRIPE_PRICE_ENTERPRISE"
|
|
||||||
printf 'STRIPE_PRICE_HARVEST_REACH=%s\n' "$STRIPE_PRICE_HARVEST_REACH"
|
|
||||||
printf 'STRIPE_PRICE_WHOLESALE_PORTAL=%s\n' "$STRIPE_PRICE_WHOLESALE_PORTAL"
|
|
||||||
printf 'STRIPE_PRICE_WATER_LOG=%s\n' "$STRIPE_PRICE_WATER_LOG"
|
|
||||||
printf 'STRIPE_PRICE_AI_TOOLS=%s\n' "$STRIPE_PRICE_AI_TOOLS"
|
|
||||||
printf 'STRIPE_PRICE_SQUARE_SYNC=%s\n' "$STRIPE_PRICE_SQUARE_SYNC"
|
|
||||||
printf 'STRIPE_PRICE_SMS_CAMPAIGNS=%s\n' "$STRIPE_PRICE_SMS_CAMPAIGNS"
|
|
||||||
printf 'RESEND_API_KEY=%s\n' "$RESEND_API_KEY"
|
|
||||||
printf 'FROM_EMAIL=%s\n' "$FROM_EMAIL"
|
|
||||||
printf 'MINIO_ENDPOINT=%s\n' "$MINIO_ENDPOINT"
|
|
||||||
printf 'MINIO_REGION=%s\n' "$MINIO_REGION"
|
|
||||||
printf 'MINIO_ACCESS_KEY=%s\n' "$MINIO_ACCESS_KEY"
|
|
||||||
printf 'MINIO_SECRET_KEY=%s\n' "$MINIO_SECRET_KEY"
|
|
||||||
printf 'MINIO_PUBLIC_URL=%s\n' "$MINIO_PUBLIC_URL"
|
|
||||||
printf 'MINIO_BUCKET_PRODUCTS=%s\n' "$MINIO_BUCKET_PRODUCTS"
|
|
||||||
printf 'MINIO_BUCKET_BRAND_LOGOS=%s\n' "$MINIO_BUCKET_BRAND_LOGOS"
|
|
||||||
printf 'MINIO_BUCKET_WATER_LOGS=%s\n' "$MINIO_BUCKET_WATER_LOGS"
|
|
||||||
printf 'MINIMAX_API_KEY=%s\n' "$MINIMAX_API_KEY"
|
|
||||||
printf 'MINIMAX_BASE_URL=%s\n' "$MINIMAX_BASE_URL"
|
|
||||||
printf 'CRON_SECRET=%s\n' "$CRON_SECRET"
|
|
||||||
} > "$ENV_FILE"
|
|
||||||
|
|
||||||
# Upload env file and sync build output
|
|
||||||
echo "Uploading env file..."
|
|
||||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no "$ENV_FILE" tyler@route.crispygoat.com:$APP_DIR/.env
|
|
||||||
|
|
||||||
echo "Copying .next/..."
|
|
||||||
scp -o ConnectTimeout=30 -o StrictHostKeyChecking=no -r .next tyler@route.crispygoat.com:$APP_DIR/
|
|
||||||
echo "Copying public/..."
|
|
||||||
scp -o ConnectTimeout=30 -o StrictHostKeyChecking=no -r public tyler@route.crispygoat.com:$APP_DIR/
|
|
||||||
echo "Copying package.json..."
|
|
||||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no package.json tyler@route.crispygoat.com:$APP_DIR/
|
|
||||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no next.config.ts tyler@route.crispygoat.com:$APP_DIR/ 2>/dev/null || true
|
|
||||||
|
|
||||||
# Ship the migration runner + SQL so the server has a recovery path (scripts/ and db/migrations/ were previously omitted from the artifact).
|
|
||||||
# This allows `node scripts/migrate.js` (after sourcing .env.production) to work directly on the target if needed for bootstrap or emergencies.
|
|
||||||
# See docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md
|
|
||||||
echo "Ensuring migration directories on server and copying runner + SQL..."
|
|
||||||
ssh -o ConnectTimeout=15 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "mkdir -p $APP_DIR/scripts $APP_DIR/db"
|
|
||||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/migrate.js tyler@route.crispygoat.com:$APP_DIR/scripts/migrate.js 2>/dev/null || true
|
|
||||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r db/migrations tyler@route.crispygoat.com:$APP_DIR/db/ 2>/dev/null || true
|
|
||||||
|
|
||||||
# Install deps and restart on server
|
|
||||||
echo "Installing deps and restarting PM2..."
|
|
||||||
ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && pm2 restart route-commerce || pm2 start npm --name route-commerce -- start -- -p 3100 && pm2 save && sleep 4 && curl -f -s http://localhost:3100/api/health/db-schema || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }"
|
|
||||||
|
|
||||||
echo "Deployed successfully"
|
echo "Deployed successfully"
|
||||||
+17
-7
@@ -39,12 +39,22 @@ next-env.d.ts
|
|||||||
# Supabase
|
# Supabase
|
||||||
supabase/.temp/
|
supabase/.temp/
|
||||||
|
|
||||||
# Playwright test results (generated, not source)
|
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
|
|
||||||
# IDE / local config
|
# IDE / local config
|
||||||
.mcp.json
|
.mcp.json
|
||||||
.env*
|
|
||||||
public/videos/tuxedo-hero.mp4
|
# Docker / self-hosted Postgres
|
||||||
.neon
|
db_data/
|
||||||
|
|
||||||
|
# Captured Supabase data (re-pull from Supabase as needed)
|
||||||
|
supabase/captured/captured_data.sql.gz
|
||||||
|
supabase/captured/captured_schema.sql
|
||||||
|
|
||||||
|
# Local data and binaries
|
||||||
|
.data/
|
||||||
|
bin/postgrest
|
||||||
|
bin/minio
|
||||||
|
bin/mc
|
||||||
|
bin/postgrest-proxy.js
|
||||||
|
bin/postgrest.tar.xz
|
||||||
|
# Captured Supabase dump (regenerate from Supabase with the guide in docs/SUPABASE_DUMP_GUIDE.md)
|
||||||
|
supabase/captured/
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# Self-Hosted Storage + Schema Plan
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The user is migrating Route Commerce from Supabase to a self-hosted stack:
|
||||||
|
- **Already done in this branch (`feat/better-auth`)**: Better Auth (auth), Docker Postgres, PostgREST, env var rewiring, ~10 source files updated to drop Supabase JS client.
|
||||||
|
- **What's broken right now**: The website is missing images (Supabase Storage URLs 404 because Supabase is going away), and the local Postgres can't apply the 137 migrations because the **base schema is missing** (the initial tables — `brands`, `orders`, `products`, `stops`, `admin_users` — were created in Supabase directly and never exported as a migration).
|
||||||
|
- **User constraint**: "no band-aids" — proper self-hosted replacement, not runtime patches.
|
||||||
|
- **User constraint**: "no data migration" — start with fresh empty DB; users re-upload assets.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Three independent workstreams, executed in order:
|
||||||
|
|
||||||
|
1. **Capture the base schema** from the still-live Supabase project using `pg_dump --schema-only` (recommended over `supabase db pull`, which has a broken migration history). Apply the captured schema + existing 137 migrations to the local self-hosted Postgres.
|
||||||
|
2. **Stand up MinIO** in Docker as the S3-compatible object store. Wire it to the app via the AWS SDK. MinIO's URL structure is clean: `http://minio:9000/<bucket>/<key>`, publicly readable per-bucket via bucket policy.
|
||||||
|
3. **Replace every Supabase Storage URL** in the codebase with MinIO URLs (one env var: `NEXT_PUBLIC_STORAGE_BASE_URL`).
|
||||||
|
|
||||||
|
Storage buckets in use (from explore agent):
|
||||||
|
| Bucket | Purpose | Current state |
|
||||||
|
|---|---|---|
|
||||||
|
| `brand-logos` | Brand logo images | Hardcoded in 8+ files via env-var URL |
|
||||||
|
| `product-images` | Product photos | `80aa01da-ab4b-44f8-b6e7-700552457e18` (Supabase bucket UUID) |
|
||||||
|
| `contacts-imports` | CSV contact imports | `a1b2c3d4-…` (Supabase bucket UUID) |
|
||||||
|
| `videos` | Tuxedo video hero | Hardcoded Supabase URL in `TuxedoVideoHero.tsx` |
|
||||||
|
| `water-photos` (dynamic) | Water log photos | API route, bucket name in form field |
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Phase A — Capture base schema and apply to local Postgres
|
||||||
|
|
||||||
|
**Why `pg_dump`, not `supabase db pull`**: The remote Supabase project's `supabase_migrations.schema_migrations` history is out of sync with the local files (that's the long error list the user got). `supabase db pull` requires that history to be in sync before it'll write a new initial migration. `pg_dump` reads the live catalog directly and ignores the migration history entirely.
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
|
||||||
|
1. **Capture the schema** from the remote Supabase DB (run on the user's Mac where direct PG works — MEMORY.md confirms direct PG is blocked from this dev box, but their Mac can do it):
|
||||||
|
```bash
|
||||||
|
pg_dump "postgresql://postgres:<service-role-pw>@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" \
|
||||||
|
--schema-only --no-owner --no-privileges \
|
||||||
|
--schema=public \
|
||||||
|
-f supabase/captured_schema.sql
|
||||||
|
```
|
||||||
|
*Exclude `auth` and `storage` schemas* — we stub `auth` ourselves and use MinIO instead of Supabase Storage. Note: 185 SECURITY DEFINER functions reference `auth.uid()`; these will compile against the preflight stub but return NULL at runtime. Phase D addresses this.
|
||||||
|
|
||||||
|
2. **Delete files that don't belong** in the local apply order:
|
||||||
|
- `BUNDLE_018_042.sql` — concatenated duplicate
|
||||||
|
- `XXX_blog_tables.sql`, `XXX_launch_checklist.sql`, `XXX_roadmap_tables.sql`, `XXX_waitlist_waitlist.sql` — drafts (XXX convention)
|
||||||
|
- `099_contact_imports_bucket.sql` (the bucket-creation one — keep the RPCs though)
|
||||||
|
- Rename to disambiguate any number collisions (no current collision, but defensive)
|
||||||
|
|
||||||
|
3. **Patch the 4 broken migrations** identified by the explore agent (read the file first, then `search_replace`):
|
||||||
|
- `006_water_log_rpcs_fixed.sql`: replace `STATIC` with `STABLE` (6 occurrences)
|
||||||
|
- `087_brand_logos_bucket.sql`: convert `CREATE POLICY IF NOT EXISTS …` to `DROP POLICY IF EXISTS …; CREATE POLICY …;` (4 policies)
|
||||||
|
- `099_harvest_reach_segmentation.sql`: quote the `time` column references (matches the 148 patch)
|
||||||
|
- `135_email_automation_rpcs.sql`: reorder `enroll_abandoned_cart` params so defaults come last, or add a default to `p_next_email_at`
|
||||||
|
|
||||||
|
4. **Update `000_preflight_supabase_compat.sql`** (already in the branch):
|
||||||
|
- Add `CREATE EXTENSION IF NOT EXISTS pgcrypto;` at the top
|
||||||
|
- Remove the `storage.buckets` / `storage.objects` stub (MinIO replaces it; 087/145 storage policies will be deleted in step 2)
|
||||||
|
|
||||||
|
5. **Apply to local Postgres** (running on this dev box, port 5432):
|
||||||
|
```bash
|
||||||
|
PGPASSWORD='lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM' \
|
||||||
|
psql -h 127.0.0.1 -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f supabase/migrations/000_preflight_supabase_compat.sql
|
||||||
|
|
||||||
|
PGPASSWORD='lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM' \
|
||||||
|
psql -h 127.0.0.1 -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f supabase/captured_schema.sql
|
||||||
|
|
||||||
|
PGPASSWORD='lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM' \
|
||||||
|
for f in supabase/migrations/[0-9]*.sql; do
|
||||||
|
psql -h 127.0.0.1 -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -q -f "$f" || break
|
||||||
|
done
|
||||||
|
```
|
||||||
|
Expected: most migrations apply, some fail (drop those, log in plan). This is the test that the local Postgres is actually usable.
|
||||||
|
|
||||||
|
### Phase B — MinIO for object storage
|
||||||
|
|
||||||
|
**Add to `docker-compose.yml`** (new `minio` service + `minio_init` one-shot to create buckets via `mc`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: route_commerce_minio
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: [.env]
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
ports: ["127.0.0.1:9000:9000", "127.0.0.1:9001:9001"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
minio_init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
depends_on:
|
||||||
|
minio: { condition: service_healthy }
|
||||||
|
env_file: [.env]
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}
|
||||||
|
for b in brand-logos product-images contacts-imports videos water-photos; do
|
||||||
|
mc mb --ignore-existing local/$${b}
|
||||||
|
mc anonymous set download local/$${b}
|
||||||
|
done
|
||||||
|
profiles: ["init"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
minio_data: { driver: local }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add to `.env.example`**:
|
||||||
|
```
|
||||||
|
MINIO_ROOT_USER=routecommerce
|
||||||
|
MINIO_ROOT_PASSWORD=change-me-minio-root
|
||||||
|
NEXT_PUBLIC_STORAGE_BASE_URL=http://localhost:9000
|
||||||
|
STORAGE_ENDPOINT=http://localhost:9000
|
||||||
|
STORAGE_REGION=us-east-1
|
||||||
|
STORAGE_ACCESS_KEY=routecommerce
|
||||||
|
STORAGE_SECRET_KEY=change-me-minio-root
|
||||||
|
STORAGE_BUCKET_PREFIX=
|
||||||
|
```
|
||||||
|
|
||||||
|
**Install AWS SDK** (MinIO is S3-compatible):
|
||||||
|
```bash
|
||||||
|
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase C — Replace Supabase Storage calls with MinIO
|
||||||
|
|
||||||
|
**New server-side helper** `src/lib/storage.ts`:
|
||||||
|
- `s3Client` configured from env (uses `@aws-sdk/client-s3`)
|
||||||
|
- `uploadFile({ bucket, key, body, contentType })` — replaces `fetch(PUT .../storage/v1/object/{bucket}/{key})` patterns
|
||||||
|
- `deleteFile({ bucket, key })`
|
||||||
|
- `publicUrl(bucket, key)` — returns `${STORAGE_BASE_URL}/${bucket}/${key}`
|
||||||
|
- Bucket name constants exported as a single source of truth
|
||||||
|
|
||||||
|
**Files to modify** (from explore agent):
|
||||||
|
- `src/actions/brand-settings.ts` — 8 `fetch` PUTs to `/storage/v1/object/brand-logos/...`
|
||||||
|
- `src/actions/products/upload-image.ts` — 3 calls to `product-images` bucket
|
||||||
|
- `src/actions/communications/import-contacts.ts` — `contacts-imports` bucket (PUT + LIST + RPC)
|
||||||
|
- `src/app/api/water-photo-upload/route.ts` — dynamic bucket
|
||||||
|
- `src/components/storefront/TuxedoVideoHero.tsx` — hardcoded `https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/...` for videos and brand-logos
|
||||||
|
- `src/components/time-tracking/TimeTrackingFieldClient.tsx` — same hardcoded URL
|
||||||
|
- `src/lib/email-service.ts` — 4 occurrences of `wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/...`
|
||||||
|
- `src/app/tuxedo/about/page.tsx` — same hardcoded URL
|
||||||
|
|
||||||
|
For the **hardcoded Supabase URLs**: replace with `publicUrl(bucket, key)` or `${process.env.NEXT_PUBLIC_STORAGE_BASE_URL}/${bucket}/${key}`.
|
||||||
|
|
||||||
|
**Remove**:
|
||||||
|
- The `MockStorageBuilder` in `src/lib/supabase.ts:193-223` (no longer needed)
|
||||||
|
- `SUPABASE_PAT` env var from `.env.example` (only used by water-photo-upload)
|
||||||
|
|
||||||
|
### Phase D — Auth wiring (closes the loop)
|
||||||
|
|
||||||
|
The 185 `auth.uid()` references in the captured schema will return NULL without intervention. Two options:
|
||||||
|
|
||||||
|
- **Option 1 (recommended, matches existing pattern)**: Disable RLS on all public tables, rely on the SECURITY DEFINER RPCs (which the codebase already does, per CLAUDE.md). One-time SQL after `captured_schema.sql` applies: `DO $$ DECLARE r record; BEGIN FOR r IN SELECT tablename FROM pg_tables WHERE schemaname='public' LOOP EXECUTE 'ALTER TABLE public.' || quote_ident(r.tablename) || ' DISABLE ROW LEVEL SECURITY'; END LOOP; END $$;`
|
||||||
|
- **Option 2**: Wire PostgREST to set `request.jwt.claim.sub` from the Better Auth session cookie. More complex; deferred unless RLS proves necessary.
|
||||||
|
|
||||||
|
**Recommend Option 1** for now — it's consistent with the existing "brand scoping in server actions" pattern, and the SECURITY DEFINER functions still work because they execute with the function owner's privileges (no RLS blocking).
|
||||||
|
|
||||||
|
### Phase E — Verification
|
||||||
|
|
||||||
|
End-to-end test sequence (no more "trust me, it works"):
|
||||||
|
|
||||||
|
1. **DB schema applies cleanly**:
|
||||||
|
```bash
|
||||||
|
docker compose up -d db minio
|
||||||
|
PGPASSWORD=$POSTGRES_PASSWORD psql -h 127.0.0.1 -U routecommerce -d route_commerce -f 000_preflight.sql
|
||||||
|
PGPASSWORD=$POSTGRES_PASSWORD psql -h 127.0.0.1 -U routecommerce -d route_commerce -f captured_schema.sql
|
||||||
|
for f in supabase/migrations/[0-9]*.sql; do psql ... -f "$f" || echo "FAIL: $f"; done
|
||||||
|
```
|
||||||
|
Goal: all migrations apply (with the 4 patches from Phase A) OR the remaining failures are documented and skipped.
|
||||||
|
|
||||||
|
2. **PostgREST serves the schema**:
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:3001/brands?limit=1 -H "apikey: local-anon"
|
||||||
|
curl http://127.0.0.1:3001/rpc/get_public_stops_for_brand -X POST -H "Content-Type: application/json" -d '{"p_slug":"indian-river-direct"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **MinIO buckets are public**:
|
||||||
|
```bash
|
||||||
|
mc alias set local http://127.0.0.1:9000 $USER $PASS
|
||||||
|
mc ls local/
|
||||||
|
curl -I http://127.0.0.1:9000/brand-logos/test.png # should return 200 or 404, never 403
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **App build passes**:
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=... NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:3001 \
|
||||||
|
NEXT_PUBLIC_STORAGE_BASE_URL=http://127.0.0.1:9000 \
|
||||||
|
BETTER_AUTH_SECRET=... npm run build
|
||||||
|
```
|
||||||
|
Goal: `✓ Compiled successfully` with no Supabase URL parse errors, no `auth.uid()` undefined, no missing bucket errors.
|
||||||
|
|
||||||
|
5. **Image display in the browser**:
|
||||||
|
- Start the dev server: `npm run dev`
|
||||||
|
- Sign in via Better Auth (mock or real)
|
||||||
|
- Upload a product image via admin UI → verify it lands in MinIO (`mc ls local/product-images/`)
|
||||||
|
- Visit `/indian-river-direct/products/[slug]` → verify the image renders with the MinIO URL
|
||||||
|
|
||||||
|
6. **Auth round-trip**:
|
||||||
|
- `POST /api/auth/sign-up/email` with test email/password → expect 200, user created in `better_auth_user` (or whatever Better Auth's table is — check `200_better_auth_tables.sql`)
|
||||||
|
- `POST /api/auth/sign-in/email` → expect session cookie
|
||||||
|
- Hit `/admin` with the cookie → expect 200, not redirect to `/login`
|
||||||
|
|
||||||
|
## Files to modify (summary)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `docker-compose.yml` | Add `minio` + `minio_init` services, `minio_data` volume |
|
||||||
|
| `.env.example` | Add MinIO + storage env vars, remove `SUPABASE_PAT` |
|
||||||
|
| `package.json` | Add `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner` |
|
||||||
|
| `supabase/migrations/000_preflight_supabase_compat.sql` | Add `pgcrypto` extension, remove storage stub |
|
||||||
|
| `supabase/migrations/006_water_log_rpcs_fixed.sql` | `STATIC` → `STABLE` |
|
||||||
|
| `supabase/migrations/087_brand_logos_bucket.sql` | `CREATE POLICY IF NOT EXISTS` → `DROP + CREATE` |
|
||||||
|
| `supabase/migrations/099_harvest_reach_segmentation.sql` | Quote `time` column refs |
|
||||||
|
| `supabase/migrations/135_email_automation_rpcs.sql` | Reorder `enroll_abandoned_cart` params |
|
||||||
|
| `supabase/captured_schema.sql` | **NEW** — output of `pg_dump` from remote |
|
||||||
|
| `src/lib/storage.ts` | **NEW** — S3 client, upload/delete/publicUrl helpers, bucket constants |
|
||||||
|
| `src/actions/brand-settings.ts` | Replace 8 Supabase fetch PUTs with `uploadFile` |
|
||||||
|
| `src/actions/products/upload-image.ts` | Replace 3 calls with `uploadFile` |
|
||||||
|
| `src/actions/communications/import-contacts.ts` | Replace PUT + LIST with `uploadFile` / S3 ListObjectsV2 |
|
||||||
|
| `src/app/api/water-photo-upload/route.ts` | Replace Supabase call with `uploadFile` |
|
||||||
|
| `src/components/storefront/TuxedoVideoHero.tsx` | Replace hardcoded Supabase URL with `publicUrl()` |
|
||||||
|
| `src/components/time-tracking/TimeTrackingFieldClient.tsx` | Same |
|
||||||
|
| `src/lib/email-service.ts` | Same (4 occurrences) |
|
||||||
|
| `src/app/tuxedo/about/page.tsx` | Same |
|
||||||
|
| `src/lib/supabase.ts` | Remove `MockStorageBuilder` (lines 193-223) |
|
||||||
|
|
||||||
|
## Reuse from existing code
|
||||||
|
|
||||||
|
- `src/lib/supabase.ts` — the `supabase` JS client still exports query builders used by `src/lib/svc-headers.ts` and many server actions for non-auth RPCs. Keep it for now; it's PostgREST-compatible because both speak the PostgREST protocol.
|
||||||
|
- `src/lib/auth.ts` — Better Auth config, already wired in this branch. No changes needed.
|
||||||
|
- `src/lib/admin-permissions.ts` — already swapped to use Better Auth + direct `pg`. No changes needed.
|
||||||
|
- `src/lib/svc-headers.ts` — used to build Supabase REST headers. Stays as-is (the anon/service-role strings still work against PostgREST).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Migration apply order**: even after patches, some migrations may reference tables that haven't been created yet due to deletion order. The captured `pg_dump` puts everything in dependency order, so applying it first should resolve most cross-references.
|
||||||
|
- **The 4 broken migrations may be load-bearing**: 087 (brand-logos RLS) is already removed in the cleanup step. 006 (water log RPCs) is critical for the `/admin/water-log` pages. 099 is critical for communications. 135 is critical for the email automation. If the patches don't work, the affected features will be broken — but the rest of the app should still build and run.
|
||||||
|
- **Existing user-uploaded images in Supabase will be unreachable**: the user said "no data migration", so this is expected. Admins will re-upload logos/product images. The Tuxedo video and Olathe logos (referenced in `email-service.ts` and `tuxedo/about/page.tsx`) are brand assets the user will need to copy over manually.
|
||||||
|
- **PostgREST connection pooling**: PostgREST opens ~10 connections to Postgres. The 137 migrations + `pg_dump` schema may reference `auth.uid()` inside SECURITY DEFINER functions, which will fail to plan if the `auth` schema is missing. The preflight stubs the function but the `pg_dump` will also try to create the same function (with the real Supabase body). If `pg_dump` includes a non-stub `auth.uid()` that conflicts with the preflight, apply `pg_dump` first, then the preflight.
|
||||||
@@ -2,23 +2,11 @@
|
|||||||
|
|
||||||
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) · **Postgres** (direct) · **Neon Auth** (Better Auth) · Stripe · Square · Resend (email) · Tailwind CSS v4
|
Tech stack: Next.js 16 (App Router) · Supabase (auth + Postgres + RLS) · Stripe · Square · Resend (email) · Tailwind CSS v4
|
||||||
|
|
||||||
> **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -33,12 +21,14 @@ npx tsc --noEmit # TypeScript check (no emit)
|
|||||||
npx playwright test # Run E2E tests (Playwright)
|
npx playwright test # Run E2E tests (Playwright)
|
||||||
```
|
```
|
||||||
|
|
||||||
> 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.
|
> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL.
|
||||||
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
|
||||||
|
> For direct PG mode: `pg` and `dotenv` are already in devDependencies.
|
||||||
|
> If `get_brand_settings` migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
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.
|
No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,91 +36,28 @@ E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.sp
|
|||||||
|
|
||||||
### Authentication & Authorization
|
### Authentication & Authorization
|
||||||
|
|
||||||
**Neon Auth (Better Auth)** is the active auth system. Config lives in `src/lib/auth.ts`, with the route handler at `src/app/api/auth/[...nextauth]/route.ts`. Neon Auth manages users in the `neon_auth.user` table; our `admin_users` table links to Neon Auth users by email.
|
**Dev mode** bypasses Supabase auth entirely via `dev_session` cookie set by `/login`:
|
||||||
|
- `dev_session=platform_admin` — full access, all brands
|
||||||
|
- `dev_session=brand_admin` — full access to assigned brand only
|
||||||
|
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
|
||||||
|
|
||||||
**Auth flow:**
|
`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.
|
||||||
1. User signs in via `/api/auth/sign-in` → Neon Auth validates credentials → session cookie set
|
|
||||||
2. `getAdminUser()` in `src/lib/admin-permissions.ts` reads the Neon Auth session and looks up `admin_users` by email
|
|
||||||
3. Middleware (`src/middleware.ts`) guards `/admin/*` routes at the edge level
|
|
||||||
|
|
||||||
**Key files:**
|
|
||||||
- `src/lib/auth.ts` — Neon Auth configuration (getSession, signIn, signOut, resetPassword, requestPasswordReset)
|
|
||||||
- `src/auth.config.ts` — Edge-safe config (baseUrl, cookieSecret)
|
|
||||||
- `src/middleware.ts` — Edge-level route protection
|
|
||||||
- `src/app/api/auth/sign-in/route.ts` — Email/password sign-in API
|
|
||||||
- `src/app/api/auth/forgot-password/route.ts` — Password reset request API
|
|
||||||
- `src/app/api/auth/reset-password/route.ts` — Password reset confirmation API
|
|
||||||
- `src/actions/auth-actions.ts` — Server actions (signOutAction)
|
|
||||||
- `src/actions/admin/password.ts` — Admin password update (setUserPassword)
|
|
||||||
|
|
||||||
**Environment variables:**
|
|
||||||
- `NEON_AUTH_BASE_URL` — from `neonctl neon-auth status`
|
|
||||||
- `NEON_AUTH_COOKIE_SECRET` — min 32-char secret: `openssl rand -base64 32`
|
|
||||||
- `NEXT_PUBLIC_SITE_URL` — site URL for redirects and Origin header
|
|
||||||
|
|
||||||
**Single source of truth for the current admin user:** `getAdminUser()` in `src/lib/admin-permissions.ts`. It reads the Neon Auth session via `getSession()` from `@/lib/auth`, then looks up `admin_users` by email. **Never import `admin-permissions.ts` into Client Components** — use server actions instead.
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
#### Auth API routes
|
|
||||||
|
|
||||||
| Route | Method | Purpose |
|
|
||||||
|-------|--------|---------|
|
|
||||||
| `/api/auth/sign-in` | POST | Email/password sign-in |
|
|
||||||
| `/api/auth/forgot-password` | POST | Request password reset email |
|
|
||||||
| `/api/auth/reset-password` | POST | Set new password (after reset link clicked) |
|
|
||||||
| `/api/auth/[...nextauth]` | GET/POST | Neon Auth handler (sign-out, OAuth callbacks, etc.) |
|
|
||||||
|
|
||||||
### 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:
|
||||||
1. Call `getAdminUser()` to verify auth
|
1. Call `getAdminUser()` to verify auth
|
||||||
2. Check role/permission flags (`can_manage_orders`, etc.)
|
2. Check role/permission flags (`can_manage_orders`, etc.)
|
||||||
3. Call Postgres RPCs via the `pg` driver
|
3. Call Supabase REST APIs (not the Supabase client directly in server actions) to trigger SECURITY DEFINER RPCs
|
||||||
4. Return typed results (`{ success: true, ... } | { success: false, error: string }`)
|
4. Return typed results (`{ success: true, ... } | { success: false, error: string }`)
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
### Database (Postgres, direct)
|
### SECURITY DEFINER RPCs + Brand Scoping
|
||||||
|
|
||||||
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.
|
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:
|
||||||
|
|
||||||
#### 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.
|
|
||||||
|
|
||||||
#### First production deploy / new prod DB bootstrap (critical for admin access)
|
|
||||||
|
|
||||||
The `admin_users` + `admin_user_brands` tables (and the rest of the schema) come **only** from `db/migrations/0001_init.sql`.
|
|
||||||
|
|
||||||
If the prod `DATABASE_URL` has never had the migrations applied, `getAdminUser()` will fail with "relation \"admin_users\" does not exist", the layout will show "Access Denied", and even a signed-in Neon Auth user will be blocked.
|
|
||||||
|
|
||||||
**Correct bootstrap sequence (do this from a machine with the full source tree before the first push that exercises /admin):**
|
|
||||||
|
|
||||||
1. Ensure the Gitea secret `DATABASE_URL` points at the real prod Neon Postgres (the one with `neon_auth.user` already present).
|
|
||||||
2. Sign in once at the live prod URL (`/login`) with the email you want as the first `platform_admin`. This creates the row in `neon_auth.user`.
|
|
||||||
3. From your laptop (or any box with the checkout):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Paste the real prod connection string (get it from Gitea secrets or the target's .env.production)
|
|
||||||
DATABASE_URL="postgresql://...prod-full-string..." node scripts/migrate.js
|
|
||||||
|
|
||||||
# Then provision (the script will link to the first brand it finds)
|
|
||||||
DATABASE_URL="postgresql://...prod-full-string..." \
|
|
||||||
npx tsx scripts/provision-admin.ts you@real.com platform_admin
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Push to main. The deploy workflow now has a hard gate (see `.gitea/workflows/deploy.yml` "Run migrations" + verification query for `admin_users`) and ships the migrate runner + SQL files, so future deploys and server-side recovery are protected.
|
|
||||||
|
|
||||||
See the full root-cause + plan: `docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md`
|
|
||||||
|
|
||||||
The old `|| echo` masking around `npm run migrate:one` has been removed; a missing critical table will now fail the CI job with a clear message.
|
|
||||||
|
|
||||||
#### 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
|
||||||
@@ -255,19 +182,10 @@ 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 "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.)
|
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`.
|
||||||
|
|
||||||
`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
|
||||||
@@ -282,7 +200,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
|||||||
|
|
||||||
## Key Conventions
|
## Key Conventions
|
||||||
|
|
||||||
- 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/`.
|
- 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)
|
||||||
- `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
|
||||||
@@ -297,15 +215,13 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
|||||||
|
|
||||||
| Concern | Location |
|
| Concern | Location |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Neon Auth configuration | `src/lib/auth.ts`, `src/auth.config.ts` |
|
|
||||||
| Auth API routes | `src/app/api/auth/sign-in/route.ts`, `src/app/api/auth/forgot-password/route.ts`, `src/app/api/auth/reset-password/route.ts`, `src/app/api/auth/[...nextauth]/route.ts` |
|
|
||||||
| 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; 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}/`) |
|
| Server actions | `src/actions/*.ts` (one file per domain) |
|
||||||
| 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 | `db/migrations/` |
|
| Migrations | `supabase/migrations/` |
|
||||||
| Postgres pool / driver | `src/lib/db.ts` (TBD) |
|
| Supabase client | `src/lib/supabase.ts` |
|
||||||
| 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` |
|
||||||
@@ -322,9 +238,7 @@ 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 no row-level policies. All brand scoping must be enforced in server actions.
|
- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have RLS disabled. All brand scoping must be enforced in server actions.
|
||||||
- **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`.
|
||||||
- **Neon Auth session cookie**: The session cookie is managed by Neon Auth. Do not manually set or clear it — use `signOut()` from `@/lib/auth` instead.
|
|
||||||
- **Password reset**: The forgot-password API returns success even if the email doesn't exist (to prevent email enumeration). Check server logs to verify reset emails were sent.
|
|
||||||
-125
@@ -1,125 +0,0 @@
|
|||||||
# Database Reference — Neon Postgres (Route Commerce)
|
|
||||||
|
|
||||||
## Core Auth
|
|
||||||
| Table | Key Columns |
|
|
||||||
|---|---|
|
|
||||||
| `users` | id, name, email, email_verified, image, created_at |
|
|
||||||
| `sessions` | id, user_id, token, expires_at, ip_address, user_agent |
|
|
||||||
| `accounts` | id, user_id, provider_id, access_token, refresh_token |
|
|
||||||
| `verifications` | id, identifier, value, expires_at |
|
|
||||||
| `admin_users` | id, user_id, email, name, **role**, can_manage_* (15 flags), created_at |
|
|
||||||
| `admin_user_brands` | admin_user_id, brand_id, added_at, added_by |
|
|
||||||
|
|
||||||
> `role` values: `platform_admin`, `brand_admin`, `store_employee`
|
|
||||||
> Link a user to a brand via `admin_user_brands`, NOT a column on `admin_users`.
|
|
||||||
|
|
||||||
## Brands & Plans
|
|
||||||
| Table | Key Columns |
|
|
||||||
|---|---|
|
|
||||||
| `brands` | id, name, slug, **plan_tier**, max_users, max_products, max_stops_monthly, stripe_* |
|
|
||||||
| `plans` | id, code, name, monthly_price_cents, max_users, max_products, max_stops_monthly, features |
|
|
||||||
| `add_ons` | id, code, name, monthly_price_cents, description |
|
|
||||||
| `brand_add_ons` | brand_id, add_on_id, stripe_subscription_id, status |
|
|
||||||
| `brand_features` | id, brand_id, feature_key, enabled, enabled_at |
|
|
||||||
| `brand_settings` | id, brand_id, legal_business_name, phone, email, logo_url, tagline, from_email, primary_color, custom_footer_text, etc. |
|
|
||||||
| `wholesale_settings` | id, brand_id, require_approval, min_order_amount, pickup_location, fob_location, from_email, invoice_business_name, last_invoice_number |
|
|
||||||
|
|
||||||
## Products & Media
|
|
||||||
| Table | Key Columns |
|
|
||||||
|---|---|
|
|
||||||
| `products` | id, brand_id, name, description, sku, type, price_cents, inventory, unit, active, is_taxable, pickup_type, image_url |
|
|
||||||
| `product_images` | id, product_id, storage_key, position, alt_text |
|
|
||||||
| `wholesale_products` | id, brand_id, rc_product_id, name, unit_type, availability, qty_available, price_tiers (JSON) |
|
|
||||||
|
|
||||||
## Orders
|
|
||||||
| Table | Key Columns |
|
|
||||||
|---|---|
|
|
||||||
| `orders` | id, brand_id, customer_id, stop_id, total_cents, status, fulfillment, customer_address, placed_at |
|
|
||||||
| `order_items` | id, order_id, product_id, quantity, price_cents, fulfillment |
|
|
||||||
| `stops` | id, brand_id, name, location, address, city, state, zip, date, time, cutoff_date, status, is_public |
|
|
||||||
| `shipments` | id, order_id, carrier, tracking_number, label_url, status, fedex_shipment_id |
|
|
||||||
| `shipping_settings` | id, brand_id, carrier, fedex_account_number, fedex_api_key, fedex_use_production |
|
|
||||||
| `user_carts` | id, brand_id, customer_id, items (JSON) |
|
|
||||||
| `abandoned_cart_recovery` | id, brand_id, customer_id, contact_email, sequence_step, status, next_email_at |
|
|
||||||
|
|
||||||
## Wholesale
|
|
||||||
| Table | Key Columns |
|
|
||||||
|---|---|
|
|
||||||
| `wholesale_customers` | id, user_id, brand_id, company_name, contact_name, email, account_status, credit_limit, deposits_enabled |
|
|
||||||
| `wholesale_orders` | id, brand_id, customer_id, status, fulfillment_status, payment_status, subtotal, deposit_paid, balance_due, invoice_number |
|
|
||||||
| `wholesale_order_items` | id, wholesale_order_id, product_id, quantity, unit_price, line_total |
|
|
||||||
| `wholesale_deposits` | id, wholesale_order_id, amount, payment_method, reference |
|
|
||||||
| `wholesale_customer_product_pricing` | id, customer_id, product_id, custom_price_cents |
|
|
||||||
| `wholesale_notifications` | id, brand_id, customer_id, notification_type, channel, status, sent_at |
|
|
||||||
| `wholesale_webhook_settings` | id, brand_id, url, secret, enabled |
|
|
||||||
| `wholesale_sync_log` | id, brand_id, event_type, order_id, status |
|
|
||||||
|
|
||||||
## Communications (Harvest Reach)
|
|
||||||
| Table | Key Columns |
|
|
||||||
|---|---|
|
|
||||||
| `communication_campaigns` | id, brand_id, name, subject, body_text, body_html, campaign_type, status, audience_rules (JSON), scheduled_at, sent_at |
|
|
||||||
| `communication_contacts` | id, brand_id, email, phone, first_name, last_name, email_opt_in, sms_opt_in, unsubscribed_at, tags |
|
|
||||||
| `communication_message_logs` | id, brand_id, campaign_id, contact_id, customer_email, delivery_method, subject, status |
|
|
||||||
| `communication_segments` | id, brand_id, name, rules (JSON) |
|
|
||||||
| `communication_templates` | id, brand_id, name, subject, body_text, body_html, template_type |
|
|
||||||
| `communication_settings` | id, brand_id, default_sender_email, email_provider |
|
|
||||||
| `welcome_email_sequence` | id, brand_id, contact_id, sequence_step, status, next_email_at |
|
|
||||||
|
|
||||||
## Customers
|
|
||||||
| Table | Key Columns |
|
|
||||||
|---|---|
|
|
||||||
| `customers` | id, brand_id, primary_email, primary_phone, first_name, last_name, source |
|
|
||||||
| `customer_communication_preferences` | id, customer_id, email_opt_in, sms_opt_in |
|
|
||||||
|
|
||||||
## Time Tracking
|
|
||||||
| Table | Key Columns |
|
|
||||||
|---|---|
|
|
||||||
| `time_tracking_workers` | id, brand_id, name, role, pin, active |
|
|
||||||
| `time_tracking_tasks` | id, brand_id, name, unit, sort_order, active |
|
|
||||||
| `time_tracking_logs` | id, brand_id, worker_id, task_id, clock_in, clock_out, lunch_break_minutes |
|
|
||||||
| `time_tracking_settings` | id, brand_id, pay_period_start_day, daily_overtime_threshold, weekly_overtime_threshold, overtime_multiplier |
|
|
||||||
| `time_tracking_notification_log` | id, brand_id, worker_id, notification_type, status, sent_at |
|
|
||||||
|
|
||||||
## Water Log
|
|
||||||
| Table | Key Columns |
|
|
||||||
|---|---|
|
|
||||||
| `water_headgates` | id, brand_id, name, active |
|
|
||||||
| `water_irrigators` | id, brand_id, name, pin_hash, active |
|
|
||||||
| `water_sessions` | id, irrigator_id, expires_at |
|
|
||||||
| `water_log_entries` | id, brand_id, headgate_id, irrigator_id, measurement, unit, notes, logged_at |
|
|
||||||
| `water_alert_log` | id, brand_id, alert_type, headgate_id, message, sent_to |
|
|
||||||
|
|
||||||
## Integrations
|
|
||||||
| Table | Key Columns |
|
|
||||||
|---|---|
|
|
||||||
| `square_sync_log` | id, brand_id, sync_type, direction, item_id, status |
|
|
||||||
| `square_sync_queue` | id, brand_id, sync_type, status, retry_count |
|
|
||||||
| `api_keys` | id, brand_id, name, key_hash, permissions (JSON), is_active |
|
|
||||||
| `payment_settings` | id, brand_id, provider, stripe_publishable_key, stripe_secret_key, square_access_token |
|
|
||||||
|
|
||||||
## Locations
|
|
||||||
| Table | Key Columns |
|
|
||||||
|---|---|
|
|
||||||
| `locations` | id, brand_id, name, address, city, state, zip, active |
|
|
||||||
|
|
||||||
## Misc
|
|
||||||
| Table | Key Columns |
|
|
||||||
|---|---|
|
|
||||||
| `audit_logs` | id, user_id, brand_id, action, entity_type, entity_id, details (JSON) |
|
|
||||||
| `admin_action_logs` | id, admin_user_id, brand_id, action, target_type, target_id, metadata (JSON) |
|
|
||||||
| `operational_events` | id, brand_id, event_type, entity_type, actor_type, payload (JSON) |
|
|
||||||
| `referral_codes` | id, brand_id, referral_code, referrer_email, reward_type, reward_value |
|
|
||||||
| `referral_redemptions` | id, referral_code_id, brand_id, referred_user_id |
|
|
||||||
| `onboarding_progress` | id, brand_id, user_id, current_step, completed_steps (JSON) |
|
|
||||||
| `notification_preferences` | id, user_id, email_orders, email_marketing, sms_orders, etc. |
|
|
||||||
| `changelogs` | — |
|
|
||||||
| `changelog_reads` | id, user_id, changelog_id |
|
|
||||||
| `_migrations` | filename, applied_at |
|
|
||||||
|
|
||||||
## Schema Notes
|
|
||||||
- All timestamps use `TIMESTAMP WITH TIME ZONE` — timezone-aware
|
|
||||||
- No PostgreSQL ENUM types — statuses are plain TEXT
|
|
||||||
- Brand scoping enforced at application layer (SECURITY DEFINER RPCs), NOT via RLS
|
|
||||||
- `brands.slug` is the public storefront URL slug (e.g. `tuxedo`, `indian-river-direct`)
|
|
||||||
- `admin_users.user_id` links to `neon_auth.user` (managed by Neon Auth), NOT to `users` table
|
|
||||||
- The `users` table above is Neon Auth's internal table — do NOT write to it directly
|
|
||||||
@@ -170,49 +170,3 @@ Controls whether to use Square sandbox or production.
|
|||||||
- **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type.
|
- **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type.
|
||||||
- **SUPABASE_SERVICE_ROLE_KEY in client:** If you ever see `SUPABASE_SERVICE_ROLE_KEY` in a browser bundle, it's a critical security incident. The key was exposed server-side. Rotate it immediately in Supabase Dashboard.
|
- **SUPABASE_SERVICE_ROLE_KEY in client:** If you ever see `SUPABASE_SERVICE_ROLE_KEY` in a browser bundle, it's a critical security incident. The key was exposed server-side. Rotate it immediately in Supabase Dashboard.
|
||||||
- **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key.
|
- **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
### Production (HTTPS Required)
|
|
||||||
|
|
||||||
Neon Auth session cookies use the `__Secure-` prefix, which requires HTTPS. In production, the auth flow works as follows:
|
|
||||||
|
|
||||||
1. User submits credentials to `/api/auth/sign-in`
|
|
||||||
2. Neon Auth server sets session cookie with `secure: true`
|
|
||||||
3. Browser stores the cookie and sends it with subsequent requests
|
|
||||||
4. Middleware validates the session cookie
|
|
||||||
|
|
||||||
### Local Development (HTTP)
|
|
||||||
|
|
||||||
For local development over HTTP (e.g., `http://localhost:4000`), the platform provides a dev_session bypass:
|
|
||||||
|
|
||||||
1. **Via Login Page:** Visit `/login` - you'll see "Dev Mode — Quick Access" buttons for Platform Admin, Brand Admin, and Store Employee roles.
|
|
||||||
2. **Via Browser Console:** `document.cookie = 'dev_session=platform_admin; path=/; max-age=86400'`
|
|
||||||
3. **Via curl:** `curl -b "dev_session=platform_admin" http://localhost:4000/admin`
|
|
||||||
|
|
||||||
The dev_session cookie is automatically recognized by:
|
|
||||||
- The middleware (`src/proxy.ts`)
|
|
||||||
- The `getAdminUser()` function (`src/lib/admin-permissions.ts`)
|
|
||||||
|
|
||||||
**Note:** The dev_session bypass only works when `NODE_ENV !== "production"`. In production, only Neon Auth session cookies are accepted.
|
|
||||||
|
|
||||||
### HTTPS for Local Development
|
|
||||||
|
|
||||||
If you prefer to test with real auth over HTTPS locally, you can use a tool like [mkcert](https://github.com/FiloSottile/mkcert):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install mkcert
|
|
||||||
brew install mkcert
|
|
||||||
|
|
||||||
# Create local CA and install it
|
|
||||||
mkcert -install
|
|
||||||
|
|
||||||
# Generate certificate for localhost
|
|
||||||
mkcert localhost 127.0.0.1 ::1
|
|
||||||
|
|
||||||
# Update next.config.ts to use HTTPS
|
|
||||||
```
|
|
||||||
|
|
||||||
For most development work, the dev_session bypass is sufficient.
|
|
||||||
@@ -2,80 +2,11 @@
|
|||||||
|
|
||||||
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 (admin_users schema fix + migration reliability + Google sign-in work)
|
**Last updated:** 2026-06-03 (during Supabase migration apply session)
|
||||||
|
|
||||||
## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043)
|
|
||||||
|
|
||||||
The 0001 schema's `admin_users` table has just `name` plus the role-derived `can_manage_*` columns (`can_manage_orders`, `can_manage_products`, `can_manage_stops`, `can_manage_customers`, `can_manage_wholesale`, `can_manage_billing`, `can_manage_settings`, `can_manage_water_log`, `can_manage_time_tracking`, `can_manage_route_trace`, `can_manage_reports`, `can_manage_communications`).
|
|
||||||
|
|
||||||
But `src/actions/admin/users.ts` was written against a richer schema: it INSERTs and SELECTs `display_name`, `phone_number`, `brand_id`, `can_manage_pickup/messages/refunds/users`, `active`, `must_change_password`, `auth_provider`, `auth_subject`, `last_login`. Submitting the create-user form produced `column "display_name" of relation "admin_users" does not exist`.
|
|
||||||
|
|
||||||
Migration `db/migrations/0043_admin_users_extra_columns.sql` adds all twelve missing columns (`ADD COLUMN IF NOT EXISTS`, re-runnable). `db/schema/brands.ts`'s Drizzle `adminUsers` definition was extended in lockstep.
|
|
||||||
|
|
||||||
**Important**: the four extra `can_manage_*` flags (pickup / messages / refunds / users) are **vestigial** — `getAdminUser()` in `lib/admin-permissions.ts` derives per-user permissions from the role via `permissionsForRole()`, never reads those columns. They exist only so the create-user form's per-user permission toggles persist; they have no runtime authorization effect. Cleanup target: either (a) read them in the role-derived lookup to support real per-user overrides, or (b) drop them from the form.
|
|
||||||
|
|
||||||
The `brand_id` column on `admin_users` is a **denormalization** of the `admin_user_brands` link table (which is the source of truth, and is what `getAdminUser()` reads). The action's `getAdminUsers` joins on `au.brand_id` so keeping the column avoids rewriting that SELECT. Future cleanup can drop the denormalized column and migrate the SELECT to join through the link table.
|
|
||||||
|
|
||||||
## 2026-06: CI migration failures on re-deploy (0001_init.sql "already exists")
|
|
||||||
|
|
||||||
Prod DATABASE_URL already had the schema from the first successful bootstrap.
|
|
||||||
The deploy workflow runs `npm run migrate:one` on every push (after neon_auth preflight).
|
|
||||||
`scripts/migrate.js` has `_migrations` tracking + skip, but the row for `0001_init.sql` was never recorded (the tracking logic landed after the initial apply, or an apply happened outside the runner).
|
|
||||||
|
|
||||||
`db/migrations/0001_init.sql` header *claimed* "CREATE TABLE IF NOT EXISTS" but the actual statements were plain `CREATE TABLE`, plain `CREATE INDEX`, and unguarded `CREATE TRIGGER`.
|
|
||||||
|
|
||||||
Result: every subsequent deploy hit `relation "admin_users" already exists` (and would have hit index/trigger dups too) inside the runner's BEGIN, causing ROLLBACK + failure of the whole "Run migrations" job.
|
|
||||||
|
|
||||||
### Fixes applied
|
|
||||||
- Made `0001_init.sql` truly re-runnable:
|
|
||||||
- All `CREATE TABLE` → `CREATE TABLE IF NOT EXISTS`
|
|
||||||
- All `CREATE INDEX` / `CREATE UNIQUE INDEX` → `... IF NOT EXISTS`
|
|
||||||
- Every `CREATE TRIGGER` wrapped in a `DO $$ IF NOT EXISTS (pg_trigger check) THEN CREATE TRIGGER ... END IF; $$` guard
|
|
||||||
- Removed the file-level `BEGIN; ... COMMIT;` (the runner owns the tx; this also prevents inner-COMMIT from ending the runner tx early).
|
|
||||||
- `0002_admin_password.sql` had its tx wrapper removed for consistency (its ALTER was already `IF NOT EXISTS`).
|
|
||||||
- Hardened `scripts/migrate.js`:
|
|
||||||
- Added `ensureTracked()` repair: for 0001/0002, if the core objects (admin_users table, or the password_hash column) already exist in the target DB but the tracking row is absent, we INSERT the row (ON CONFLICT DO NOTHING) and skip the file. Logs "repaired tracking".
|
|
||||||
- Hardened `.gitea/workflows/deploy.yml` "Run migrations" step:
|
|
||||||
- Added an inline pre-repair node snippet (same idea) right before `npm run migrate:one`. This protects even if an older runner is checked out.
|
|
||||||
- The existing neon_auth preflight + post `admin_users` verification remain as the hard gate.
|
|
||||||
- Updated header comments and docs in the files.
|
|
||||||
|
|
||||||
After this, `npm run migrate` / deploys on an already-initialized DB will log the "repaired" or "already applied" lines for 0001 and proceed cleanly. The shipped `scripts/migrate.js` + `db/migrations/` on the target server also benefit for emergency recovery runs.
|
|
||||||
|
|
||||||
See also the plan doc referenced in deploy.yml for the broader reliability work.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
|
## Supabase CLI + Migrations Tooling
|
||||||
|
|
||||||
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`
|
||||||
@@ -104,23 +35,15 @@ 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 (Supabase CLI path — being phased out, use `pg` direct path going forward):**
|
**Recommended commands now:**
|
||||||
```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 # CLI path
|
node supabase/push-migrations.js 148 # or any prefix
|
||||||
# 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -210,23 +133,19 @@ Verification queries (post-apply) confirmed:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current State / Gotchas (2026-06-06)
|
## Current State / Gotchas
|
||||||
|
|
||||||
- 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.
|
- `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 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.)
|
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project.
|
||||||
- `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.
|
- 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.
|
||||||
- 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`.
|
- Storage policies, RLS, SECURITY DEFINER functions, and brand-scoped data are all over the place — test carefully after big applies.
|
||||||
- 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.
|
||||||
|
|
||||||
@@ -326,274 +245,3 @@ 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)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 2026-06-07 — Full Supabase → Drizzle/pg migration complete
|
|
||||||
|
|
||||||
**Status: ALL 114 FILES MIGRATED. ZERO @supabase IMPORTS. ZERO rest/v1 CALLS.**
|
|
||||||
|
|
||||||
## Migration waves (all committed to main)
|
|
||||||
|
|
||||||
| Wave | Branch | Commit | Files | Focus |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | main | `eb9621d` | ~25 | Core admin (orders, products, stops, etc.) |
|
|
||||||
| 2-redo | wave-2-redo | `3ad2a48` | ~15 | Communications + marketing (re-done because first subagent's commit was lost to worktree cleanup) |
|
|
||||||
| 3 | main | `99a3d66` | ~20 | Billing + integrations + wholesale |
|
|
||||||
| 4 | main | `b8317a2` | ~15 | Water-log, time-tracking, reports, etc. |
|
|
||||||
| 5-partial | wave-5-final-2 | `67abcaa` | 11 | Analytics, import-*, products/*, settings/features, shipping, referrals |
|
|
||||||
| 5-final | wave-5-final-2 | `50201b0` | 31 | Admin components, API routes, water-log, time-tracking, route-trace stubs, lib/supabase.ts (rewritten as pure mock), lib/supabase/server.ts (deleted), api/supabase/route.ts (deleted) |
|
|
||||||
|
|
||||||
## Final merge to main
|
|
||||||
|
|
||||||
`e7de43e merge: wave-5-final-2 (28 files: Supabase → Drizzle/pg migration complete)`
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `grep -rln 'rest/v1\|@supabase' src/` → **0 files**
|
|
||||||
- `npx tsc --noEmit` → **clean** (0 errors)
|
|
||||||
- `npm run test` → **22/22 pass**
|
|
||||||
- `npm run build` → **succeeded** (89/89 static pages)
|
|
||||||
- `npx playwright test --project=local` → **10/14 pass** (4 failures are pre-existing: Google OAuth not configured + missing /wholesale route, not migration-related)
|
|
||||||
|
|
||||||
## Migration patterns established
|
|
||||||
|
|
||||||
- **Read queries** → `pool.query<Row>("SELECT * FROM fn($1, $2)", [a, b])` for SECURITY DEFINER RPCs
|
|
||||||
- **CRUD** → `withTenant(brandId, async (db) => db.select().from(table).where(eq(table.tenantId, brandId)))` for tenant-scoped reads
|
|
||||||
- **Writes** → `withTx(async (tx) => { ... })` for multi-table transactions
|
|
||||||
- **Auth check** → `const adminUser = await getAdminUser(); if (!adminUser) return ...`
|
|
||||||
- **Brand scoping** → `effectiveBrandId = brandId ?? adminUser.brand_id ?? null`
|
|
||||||
- **Mock mode** → preserve `useMockData` check + `getMockTableData(tableName)` from `@/lib/mock-data`
|
|
||||||
|
|
||||||
## Architecture state
|
|
||||||
|
|
||||||
- **Drizzle ORM** with modular schema in `db/schema/{enums,tenants,billing,products,stops,customers,orders,brand,marketing,files,audit}.ts`
|
|
||||||
- **Postgres + RLS** with `withTenant()` setting `app.current_tenant_id` GUC
|
|
||||||
- **Auth.js v5** (next-auth@5.0.0-beta.31) with Google OAuth + Credentials providers
|
|
||||||
- **`src/lib/db.ts`** — shared `pg.Pool` (server-only, lazy)
|
|
||||||
- **`db/client.ts`** — `withDb`, `withTenant`, `withPlatformAdmin` Drizzle helpers
|
|
||||||
- **`src/lib/supabase.ts`** — rewrote as pure mock (no @supabase/* imports) for the ~25 legacy consumers that still use the query-builder API; can be removed when those consumers migrate to Drizzle
|
|
||||||
- **`src/lib/supabase/server.ts`** — DELETED
|
|
||||||
- **`src/app/api/supabase/route.ts`** — DELETED
|
|
||||||
- **Route-trace API routes** — all stubbed with 404 (feature retired from SaaS rebuild)
|
|
||||||
|
|
||||||
## What's left (separate tasks, not part of Full Supabase migration)
|
|
||||||
|
|
||||||
- Google OAuth setup (needs `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` env)
|
|
||||||
- Wholesale auth migration (currently still uses Supabase Auth for wholesale customers)
|
|
||||||
- Migrate the remaining ~25 legacy consumers that use `supabase.from('table').select()` API (these still work via the mock rewrite, but should be Drizzle-ified)
|
|
||||||
- Add `email` column to `admin_users` and provision Google users by email
|
|
||||||
- Switch `getAdminUser()` to direct `pg` lookup (not REST)
|
|
||||||
- Remove the email/password (Supabase) provider when Supabase auth is fully cut over
|
|
||||||
- Remove `DEV_FORCE_UID` constant and dead branches in `actions/admin/users.ts`
|
|
||||||
|
|
||||||
## Pushed to remotes
|
|
||||||
|
|
||||||
- `crispygoat/main` (the SSH remote at git.crispygoat.com) — primary target
|
|
||||||
- `origin/main` (GitHub) — for backup visibility
|
|
||||||
- 36 commits ahead of `origin/main` as of this entry
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Route Commerce
|
# Route Commerce
|
||||||
|
|
||||||
A multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands manage products, stops, orders, and wholesale customers from a single admin dashboard.
|
A multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands manage products, stops, orders, and wholesale customers from a single admin dashboard. 🚀
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ The admin dashboard lives at `/admin`:
|
|||||||
- **Communications** — Harvest Reach campaign manager
|
- **Communications** — Harvest Reach campaign manager
|
||||||
- **Wholesale** — Wholesale portal settings
|
- **Wholesale** — Wholesale portal settings
|
||||||
- **Billing** — Plan and subscription management
|
- **Billing** — Plan and subscription management
|
||||||
- **Water Log** — Irrigation tracking (add-on) — see [docs/water-log.md](docs/water-log.md)
|
- **Water Log** — Irrigation tracking (add-on)
|
||||||
- **Settings** — Brand settings, payments, apps
|
- **Settings** — Brand settings, payments, apps
|
||||||
|
|
||||||
## Email Automations (Harvest Reach)
|
## Email Automations (Harvest Reach)
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
# 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)
|
|
||||||
```
|
|
||||||
-135
@@ -1,135 +0,0 @@
|
|||||||
/**
|
|
||||||
* Drizzle client + brand-scoped query helper.
|
|
||||||
*
|
|
||||||
* The app connects to Postgres directly via the `pg` driver. Drizzle sits
|
|
||||||
* on top, providing typed queries. The `withBrand` wrapper is the only
|
|
||||||
* sanctioned way to run a brand-scoped query — it sets the
|
|
||||||
* `app.current_brand_id` GUC transaction-locally, and the database's
|
|
||||||
* RLS policies enforce brand isolation even if application code forgets
|
|
||||||
* a `WHERE brand_id = $1`.
|
|
||||||
*
|
|
||||||
* Usage (read):
|
|
||||||
* const products = await withBrand(brandId, (db) =>
|
|
||||||
* db.select().from(productsTable).where(eq(productsTable.active, true)),
|
|
||||||
* );
|
|
||||||
*
|
|
||||||
* Usage (platform admin — sees all brands):
|
|
||||||
* const allBrands = await withPlatformAdmin((db) =>
|
|
||||||
* db.select().from(brandsTable),
|
|
||||||
* );
|
|
||||||
*
|
|
||||||
* Usage (no brand — for the rare case the query isn't brand-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 brand context is set — the caller
|
|
||||||
* is responsible for RLS bypass (e.g. for the `plans` and `add_ons` tables,
|
|
||||||
* which are not brand-scoped). For brand-scoped reads, prefer
|
|
||||||
* `withBrand` 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 brand id set as a
|
|
||||||
* transaction-local GUC. RLS policies on brand-scoped tables will allow
|
|
||||||
* reads/writes only for rows where `brand_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 withBrand<T>(
|
|
||||||
brandId: 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_brand_id', $1, true)", [
|
|
||||||
brandId,
|
|
||||||
]);
|
|
||||||
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 brands.
|
|
||||||
* 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.current_brand_id', '', true)");
|
|
||||||
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 };
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
|||||||
-- 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.
|
|
||||||
|
|
||||||
-- Transaction is managed by the migrate runner (scripts/migrate.js).
|
|
||||||
-- File kept small and re-runnable via IF NOT EXISTS on the ALTER.
|
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
-- Migration 003: Batch insert RPCs for tour stop / location seeding
|
|
||||||
-- Required by db/seeds/2026-tuxedo-tour-stops.sql and scripts/import-tuxedo-stops.ts
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- admin_create_locations_batch: insert or update locations from tour seed
|
|
||||||
-- Payload shape: { name, address, city, state, zip, phone, contact_name, contact_email, notes, active }[]
|
|
||||||
CREATE OR REPLACE FUNCTION admin_create_locations_batch(p_brand_id UUID, p_locations JSONB)
|
|
||||||
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_loc JSONB;
|
|
||||||
BEGIN
|
|
||||||
FOR v_loc IN SELECT * FROM jsonb_array_elements(p_locations)
|
|
||||||
LOOP
|
|
||||||
INSERT INTO locations (
|
|
||||||
brand_id, name, address, city, state, zip,
|
|
||||||
phone, contact_name, contact_email, notes, active
|
|
||||||
) VALUES (
|
|
||||||
p_brand_id,
|
|
||||||
v_loc->>'name',
|
|
||||||
v_loc->>'address',
|
|
||||||
v_loc->>'city',
|
|
||||||
v_loc->>'state',
|
|
||||||
NULLIF(v_loc->>'zip', '')::TEXT,
|
|
||||||
v_loc->>'phone',
|
|
||||||
v_loc->>'contact_name',
|
|
||||||
v_loc->>'contact_email',
|
|
||||||
v_loc->>'notes',
|
|
||||||
COALESCE((v_loc->>'active')::BOOLEAN, true)
|
|
||||||
)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
END LOOP;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- admin_create_stops_batch: insert stops from tour seed
|
|
||||||
-- Payload shape: { city, state, location, date, time, address, zip, cutoff_time, active, notes }[]
|
|
||||||
-- date format: '2026-07-22 00:00:00+00' — cast to DATE
|
|
||||||
CREATE OR REPLACE FUNCTION admin_create_stops_batch(p_brand_id UUID, p_stops JSONB)
|
|
||||||
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_stop JSONB;
|
|
||||||
BEGIN
|
|
||||||
FOR v_stop IN SELECT * FROM jsonb_array_elements(p_stops)
|
|
||||||
LOOP
|
|
||||||
INSERT INTO stops (
|
|
||||||
brand_id, name, location, address, city, state, zip,
|
|
||||||
date, "time", cutoff_date, status, is_public, notes
|
|
||||||
) VALUES (
|
|
||||||
p_brand_id,
|
|
||||||
COALESCE(NULLIF(v_stop->>'name', ''), (v_stop->>'location')::TEXT || ' - ' || (v_stop->>'city')::TEXT || ', ' || (v_stop->>'state')::TEXT),
|
|
||||||
v_stop->>'location',
|
|
||||||
v_stop->>'address',
|
|
||||||
v_stop->>'city',
|
|
||||||
v_stop->>'state',
|
|
||||||
NULLIF(v_stop->>'zip', '')::TEXT,
|
|
||||||
CASE
|
|
||||||
WHEN v_stop->>'date' IS NULL THEN NULL
|
|
||||||
ELSE LEFT(v_stop->>'date', 10)::DATE
|
|
||||||
END,
|
|
||||||
v_stop->>'time',
|
|
||||||
NULLIF(v_stop->>'cutoff_time', '')::DATE,
|
|
||||||
'active',
|
|
||||||
COALESCE((v_stop->>'active')::BOOLEAN, true),
|
|
||||||
v_stop->>'notes'
|
|
||||||
);
|
|
||||||
END LOOP;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
-- Migration 0040: Platform command center — founder_pain_log table/view + RPCs
|
|
||||||
-- Restores objects that the /admin/command-center page depends on.
|
|
||||||
-- Original definitions were in archived Supabase migrations 126 + 127.
|
|
||||||
|
|
||||||
-- ── founder_pain_log table ───────────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS founder_pain_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
|
|
||||||
severity TEXT NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
|
|
||||||
category TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved')),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
resolved_at TIMESTAMPTZ,
|
|
||||||
resolved_by UUID REFERENCES admin_users(id) ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ── founder_pain_log_platform view (joins brand name/slug for the UI) ────────
|
|
||||||
CREATE OR REPLACE VIEW founder_pain_log_platform AS
|
|
||||||
SELECT
|
|
||||||
fpl.id,
|
|
||||||
fpl.brand_id,
|
|
||||||
fpl.severity,
|
|
||||||
fpl.category,
|
|
||||||
fpl.title,
|
|
||||||
fpl.description,
|
|
||||||
fpl.status,
|
|
||||||
fpl.created_at,
|
|
||||||
fpl.resolved_at,
|
|
||||||
fpl.resolved_by,
|
|
||||||
b.name AS brand_name,
|
|
||||||
b.slug AS brand_slug
|
|
||||||
FROM founder_pain_log fpl
|
|
||||||
LEFT JOIN brands b ON b.id = fpl.brand_id
|
|
||||||
ORDER BY
|
|
||||||
CASE fpl.severity
|
|
||||||
WHEN 'critical' THEN 1
|
|
||||||
WHEN 'high' THEN 2
|
|
||||||
WHEN 'medium' THEN 3
|
|
||||||
WHEN 'low' THEN 4
|
|
||||||
END,
|
|
||||||
fpl.created_at DESC;
|
|
||||||
|
|
||||||
-- ── Platform-wide metrics ────────────────────────────────────────────────────
|
|
||||||
CREATE OR REPLACE FUNCTION get_platform_command_center_metrics()
|
|
||||||
RETURNS JSONB
|
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
result JSONB;
|
|
||||||
BEGIN
|
|
||||||
result := jsonb_build_object(
|
|
||||||
'active_brands', (
|
|
||||||
SELECT COUNT(*) FROM brands WHERE slug IN ('tuxedo', 'indian-river-direct')
|
|
||||||
),
|
|
||||||
'orders_today', (
|
|
||||||
SELECT COUNT(*) FROM orders
|
|
||||||
WHERE DATE(created_at) = CURRENT_DATE
|
|
||||||
),
|
|
||||||
'revenue_today', (
|
|
||||||
SELECT COALESCE(SUM(subtotal), 0)
|
|
||||||
FROM orders
|
|
||||||
WHERE DATE(created_at) = CURRENT_DATE
|
|
||||||
AND status NOT IN ('cancelled', 'refunded')
|
|
||||||
),
|
|
||||||
'active_routes', (
|
|
||||||
SELECT COUNT(DISTINCT s.id)
|
|
||||||
FROM stops s
|
|
||||||
WHERE s.date::DATE >= CURRENT_DATE
|
|
||||||
AND s.status = 'active'
|
|
||||||
AND s.date ~ '^\d{4}-\d{2}-\d{2}$'
|
|
||||||
),
|
|
||||||
'failed_orders_today', (
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM orders
|
|
||||||
WHERE DATE(created_at) = CURRENT_DATE
|
|
||||||
AND status IN ('payment_failed', 'failed')
|
|
||||||
),
|
|
||||||
'pending_orders_today', (
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM orders
|
|
||||||
WHERE DATE(created_at) = CURRENT_DATE
|
|
||||||
AND status = 'pending'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
RETURN result;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- ── Recent activity feed (last 50 operational events across all brands) ──────
|
|
||||||
CREATE OR REPLACE FUNCTION get_platform_activity_feed()
|
|
||||||
RETURNS TABLE (
|
|
||||||
id UUID,
|
|
||||||
event_type TEXT,
|
|
||||||
entity_type TEXT,
|
|
||||||
entity_id UUID,
|
|
||||||
payload JSONB,
|
|
||||||
actor_type TEXT,
|
|
||||||
source TEXT,
|
|
||||||
created_at TIMESTAMPTZ,
|
|
||||||
brand_id UUID,
|
|
||||||
brand_name TEXT
|
|
||||||
)
|
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
|
||||||
AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN QUERY
|
|
||||||
SELECT
|
|
||||||
oe.id,
|
|
||||||
oe.event_type,
|
|
||||||
oe.entity_type,
|
|
||||||
oe.entity_id,
|
|
||||||
oe.payload,
|
|
||||||
oe.actor_type::TEXT,
|
|
||||||
oe.source::TEXT,
|
|
||||||
oe.created_at,
|
|
||||||
(oe.payload->>'brand_id')::UUID AS brand_id,
|
|
||||||
b.name AS brand_name
|
|
||||||
FROM operational_events oe
|
|
||||||
LEFT JOIN brands b ON b.id = (oe.payload->>'brand_id')::UUID
|
|
||||||
ORDER BY oe.created_at DESC
|
|
||||||
LIMIT 50;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- ── Per-brand health snapshot ────────────────────────────────────────────────
|
|
||||||
CREATE OR REPLACE FUNCTION get_brand_health_snapshot()
|
|
||||||
RETURNS TABLE (
|
|
||||||
brand_id UUID,
|
|
||||||
brand_name TEXT,
|
|
||||||
brand_slug TEXT,
|
|
||||||
plan_tier TEXT,
|
|
||||||
orders_today INT,
|
|
||||||
revenue_today NUMERIC,
|
|
||||||
active_stops INT,
|
|
||||||
failed_orders INT,
|
|
||||||
pending_orders INT,
|
|
||||||
last_activity TIMESTAMPTZ,
|
|
||||||
open_pain_items INT,
|
|
||||||
health_status TEXT
|
|
||||||
)
|
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
|
||||||
AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN QUERY
|
|
||||||
WITH bm AS (
|
|
||||||
SELECT
|
|
||||||
b.id AS bid,
|
|
||||||
b.name AS bname,
|
|
||||||
b.slug AS bslug,
|
|
||||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE) AS ords_today,
|
|
||||||
SUM(o.subtotal) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status NOT IN ('cancelled', 'refunded')) AS rev_today,
|
|
||||||
COUNT(DISTINCT s.id) FILTER (
|
|
||||||
WHERE (s.date ~ '^\d{4}-\d{2}-\d{2}$') AND (s.date::DATE >= CURRENT_DATE) AND s.status = 'active'
|
|
||||||
) AS act_stops,
|
|
||||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status IN ('payment_failed', 'failed')) AS fail_ords,
|
|
||||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status = 'pending') AS pend_ords,
|
|
||||||
MAX(oe.created_at) AS last_act
|
|
||||||
FROM brands b
|
|
||||||
LEFT JOIN orders o ON o.brand_id = b.id
|
|
||||||
LEFT JOIN stops s ON s.brand_id = b.id
|
|
||||||
LEFT JOIN operational_events oe ON oe.payload->>'brand_id' = b.id::TEXT
|
|
||||||
WHERE b.slug IN ('tuxedo', 'indian-river-direct')
|
|
||||||
GROUP BY b.id, b.name, b.slug
|
|
||||||
),
|
|
||||||
pc AS (
|
|
||||||
SELECT f.brand_id AS pc_bid, COUNT(*) AS open_cnt
|
|
||||||
FROM founder_pain_log f
|
|
||||||
WHERE f.status = 'open'
|
|
||||||
GROUP BY f.brand_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
bm.bid,
|
|
||||||
bm.bname,
|
|
||||||
bm.bslug,
|
|
||||||
NULL::TEXT,
|
|
||||||
COALESCE(bm.ords_today, 0)::INT,
|
|
||||||
COALESCE(bm.rev_today, 0)::NUMERIC,
|
|
||||||
COALESCE(bm.act_stops, 0)::INT,
|
|
||||||
COALESCE(bm.fail_ords, 0)::INT,
|
|
||||||
COALESCE(bm.pend_ords, 0)::INT,
|
|
||||||
bm.last_act,
|
|
||||||
COALESCE(pc.open_cnt, 0)::INT,
|
|
||||||
CASE
|
|
||||||
WHEN COALESCE(bm.fail_ords, 0) > 0 THEN 'critical'
|
|
||||||
WHEN COALESCE(bm.pend_ords, 0) > 5 THEN 'warning'
|
|
||||||
WHEN COALESCE(pc.open_cnt, 0) > 0 THEN 'warning'
|
|
||||||
WHEN bm.last_act IS NULL OR bm.last_act < (NOW() - INTERVAL '24 hours') THEN 'warning'
|
|
||||||
ELSE 'healthy'
|
|
||||||
END
|
|
||||||
FROM bm
|
|
||||||
LEFT JOIN pc ON pc.pc_bid = bm.bid;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
-- Migration 0041: Fix command-center RPCs against actual prod schema
|
|
||||||
--
|
|
||||||
-- Migration 0040 was written against an assumed schema and references columns
|
|
||||||
-- that don't exist in the prod `orders` table:
|
|
||||||
-- - `orders.created_at` does not exist → use `orders.placed_at`
|
|
||||||
-- - `orders.subtotal` does not exist → use `orders.total_cents / 100`
|
|
||||||
-- - `stops.date` is already DATE, not TEXT → drop the `~ '^\d{4}-...' ` regex
|
|
||||||
--
|
|
||||||
-- All three functions are fixed in place via CREATE OR REPLACE FUNCTION.
|
|
||||||
-- The action layer (src/actions/platform/command-center.ts) is updated
|
|
||||||
-- separately to use `SELECT * FROM fn()` for the two setof-returning functions.
|
|
||||||
--
|
|
||||||
-- Reference (prod schema as of 2026-06-17):
|
|
||||||
-- orders: id, brand_id, customer_id, stop_id, total_cents INT,
|
|
||||||
-- status TEXT, fulfillment TEXT, customer_*,
|
|
||||||
-- idempotency_key, notes, placed_at TIMESTAMPTZ, updated_at
|
|
||||||
-- stops: id, brand_id, name, location, address, city, state, zip,
|
|
||||||
-- date DATE, time, cutoff_date, status, is_public, notes,
|
|
||||||
-- created_at, updated_at
|
|
||||||
-- operational_events: id, brand_id, event_type, entity_type, entity_id,
|
|
||||||
-- actor_type, actor_id, source, payload JSONB, created_at
|
|
||||||
-- brands: (assumed present per CLAUDE.md)
|
|
||||||
|
|
||||||
-- ── Platform-wide metrics ──────────────────────────────────────────────────
|
|
||||||
CREATE OR REPLACE FUNCTION get_platform_command_center_metrics()
|
|
||||||
RETURNS JSONB
|
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
result JSONB;
|
|
||||||
BEGIN
|
|
||||||
result := jsonb_build_object(
|
|
||||||
'active_brands', (
|
|
||||||
SELECT COUNT(*) FROM brands WHERE slug IN ('tuxedo', 'indian-river-direct')
|
|
||||||
),
|
|
||||||
'orders_today', (
|
|
||||||
SELECT COUNT(*) FROM orders
|
|
||||||
WHERE DATE(placed_at) = CURRENT_DATE
|
|
||||||
),
|
|
||||||
'revenue_today', (
|
|
||||||
SELECT COALESCE(SUM(total_cents), 0)::NUMERIC / 100
|
|
||||||
FROM orders
|
|
||||||
WHERE DATE(placed_at) = CURRENT_DATE
|
|
||||||
AND status NOT IN ('cancelled', 'refunded')
|
|
||||||
),
|
|
||||||
'active_routes', (
|
|
||||||
SELECT COUNT(DISTINCT s.id)
|
|
||||||
FROM stops s
|
|
||||||
WHERE s.date >= CURRENT_DATE
|
|
||||||
AND s.status = 'active'
|
|
||||||
),
|
|
||||||
'failed_orders_today', (
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM orders
|
|
||||||
WHERE DATE(placed_at) = CURRENT_DATE
|
|
||||||
AND status IN ('payment_failed', 'failed')
|
|
||||||
),
|
|
||||||
'pending_orders_today', (
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM orders
|
|
||||||
WHERE DATE(placed_at) = CURRENT_DATE
|
|
||||||
AND status = 'pending'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
RETURN result;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- ── Per-brand health snapshot ──────────────────────────────────────────────
|
|
||||||
CREATE OR REPLACE FUNCTION get_brand_health_snapshot()
|
|
||||||
RETURNS TABLE (
|
|
||||||
brand_id UUID,
|
|
||||||
brand_name TEXT,
|
|
||||||
brand_slug TEXT,
|
|
||||||
plan_tier TEXT,
|
|
||||||
orders_today INT,
|
|
||||||
revenue_today NUMERIC,
|
|
||||||
active_stops INT,
|
|
||||||
failed_orders INT,
|
|
||||||
pending_orders INT,
|
|
||||||
last_activity TIMESTAMPTZ,
|
|
||||||
open_pain_items INT,
|
|
||||||
health_status TEXT
|
|
||||||
)
|
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
|
||||||
AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN QUERY
|
|
||||||
WITH bm AS (
|
|
||||||
SELECT
|
|
||||||
b.id AS bid,
|
|
||||||
b.name AS bname,
|
|
||||||
b.slug AS bslug,
|
|
||||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE) AS ords_today,
|
|
||||||
(COALESCE(SUM(o.total_cents) FILTER (
|
|
||||||
WHERE DATE(o.placed_at) = CURRENT_DATE
|
|
||||||
AND o.status NOT IN ('cancelled', 'refunded')
|
|
||||||
), 0)::NUMERIC / 100) AS rev_today,
|
|
||||||
COUNT(DISTINCT s.id) FILTER (
|
|
||||||
WHERE s.date >= CURRENT_DATE AND s.status = 'active'
|
|
||||||
) AS act_stops,
|
|
||||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE AND o.status IN ('payment_failed', 'failed')) AS fail_ords,
|
|
||||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE AND o.status = 'pending') AS pend_ords,
|
|
||||||
MAX(oe.created_at) AS last_act
|
|
||||||
FROM brands b
|
|
||||||
LEFT JOIN orders o ON o.brand_id = b.id
|
|
||||||
LEFT JOIN stops s ON s.brand_id = b.id
|
|
||||||
LEFT JOIN operational_events oe
|
|
||||||
ON oe.payload->>'brand_id' = b.id::TEXT
|
|
||||||
AND oe.payload->>'brand_id' ~ '^[0-9a-fA-F-]{36}$'
|
|
||||||
WHERE b.slug IN ('tuxedo', 'indian-river-direct')
|
|
||||||
GROUP BY b.id, b.name, b.slug
|
|
||||||
),
|
|
||||||
pc AS (
|
|
||||||
SELECT f.brand_id AS pc_bid, COUNT(*) AS open_cnt
|
|
||||||
FROM founder_pain_log f
|
|
||||||
WHERE f.status = 'open'
|
|
||||||
GROUP BY f.brand_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
bm.bid,
|
|
||||||
bm.bname,
|
|
||||||
bm.bslug,
|
|
||||||
NULL::TEXT,
|
|
||||||
COALESCE(bm.ords_today, 0)::INT,
|
|
||||||
COALESCE(bm.rev_today, 0)::NUMERIC,
|
|
||||||
COALESCE(bm.act_stops, 0)::INT,
|
|
||||||
COALESCE(bm.fail_ords, 0)::INT,
|
|
||||||
COALESCE(bm.pend_ords, 0)::INT,
|
|
||||||
bm.last_act,
|
|
||||||
COALESCE(pc.open_cnt, 0)::INT,
|
|
||||||
CASE
|
|
||||||
WHEN COALESCE(bm.fail_ords, 0) > 0 THEN 'critical'
|
|
||||||
WHEN COALESCE(bm.pend_ords, 0) > 5 THEN 'warning'
|
|
||||||
WHEN COALESCE(pc.open_cnt, 0) > 0 THEN 'warning'
|
|
||||||
WHEN bm.last_act IS NULL OR bm.last_act < (NOW() - INTERVAL '24 hours') THEN 'warning'
|
|
||||||
ELSE 'healthy'
|
|
||||||
END
|
|
||||||
FROM bm
|
|
||||||
LEFT JOIN pc ON pc.pc_bid = bm.bid;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- get_platform_activity_feed from 0040 is already correct against the prod
|
|
||||||
-- schema (operational_events.payload->>'brand_id' is a text→UUID cast that
|
|
||||||
-- nulls out on missing keys). No body change required.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- Migration 0042: Drop command-center RPCs and founder_pain_log
|
|
||||||
--
|
|
||||||
-- The /admin/command-center page is being removed (see commit message).
|
|
||||||
-- All objects it depended on are also dropped:
|
|
||||||
-- - get_platform_command_center_metrics (JSONB-returning RPC)
|
|
||||||
-- - get_platform_activity_feed (TABLE-returning RPC)
|
|
||||||
-- - get_brand_health_snapshot (TABLE-returning RPC)
|
|
||||||
-- - founder_pain_log (table)
|
|
||||||
-- - founder_pain_log_platform (view, joins brand name onto founder_pain_log)
|
|
||||||
--
|
|
||||||
-- The platform_admin role has no other consumer of these objects. The
|
|
||||||
-- cross-brand KPIs and activity feed that the page showed are reachable
|
|
||||||
-- per-brand via /admin, /admin/orders, /admin/stops, and /admin/reports.
|
|
||||||
|
|
||||||
DROP VIEW IF EXISTS founder_pain_log_platform;
|
|
||||||
DROP FUNCTION IF EXISTS get_brand_health_snapshot();
|
|
||||||
DROP FUNCTION IF EXISTS get_platform_activity_feed();
|
|
||||||
DROP FUNCTION IF EXISTS get_platform_command_center_metrics();
|
|
||||||
DROP TABLE IF EXISTS founder_pain_log;
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
-- 0043_admin_users_extra_columns.sql
|
|
||||||
--
|
|
||||||
-- The application-layer admin user CRUD in `src/actions/admin/users.ts`
|
|
||||||
-- was written for a richer `admin_users` schema than the one that
|
|
||||||
-- landed in 0001_init.sql. The 0001 schema has just `name` plus the
|
|
||||||
-- role-derived flag columns (`can_manage_orders`, `can_manage_products`,
|
|
||||||
-- `can_manage_stops`, `can_manage_customers`, `can_manage_wholesale`,
|
|
||||||
-- `can_manage_billing`, `can_manage_settings`, `can_manage_water_log`,
|
|
||||||
-- `can_manage_time_tracking`, `can_manage_route_trace`,
|
|
||||||
-- `can_manage_reports`, `can_manage_communications`).
|
|
||||||
--
|
|
||||||
-- The action also references these columns, which are not in 0001:
|
|
||||||
--
|
|
||||||
-- display_name, phone_number, brand_id,
|
|
||||||
-- can_manage_pickup, can_manage_messages,
|
|
||||||
-- can_manage_refunds, can_manage_users,
|
|
||||||
-- active, must_change_password,
|
|
||||||
-- auth_provider, auth_subject, last_login
|
|
||||||
--
|
|
||||||
-- This migration adds all of them so the create-user, list-users,
|
|
||||||
-- update-user, and read-user flows work against the actual table.
|
|
||||||
-- Re-runnable: each ALTER uses ADD COLUMN IF NOT EXISTS.
|
|
||||||
--
|
|
||||||
-- Notes on the new columns:
|
|
||||||
--
|
|
||||||
-- - `display_name` is what the UI shows in the user list / "logged in
|
|
||||||
-- as" labels. The original `name` column from 0001 is left in place
|
|
||||||
-- (Drizzle `adminUsers.name` still maps to it; `getAdminUser()` reads
|
|
||||||
-- it) — `display_name` is the column the action's SQL touches so it
|
|
||||||
-- stays the writable surface for the create-user form. Both can
|
|
||||||
-- coexist; the next cleanup pass can collapse them.
|
|
||||||
--
|
|
||||||
-- - The four extra `can_manage_*` flags (pickup / messages / refunds /
|
|
||||||
-- users) are not consulted by `getAdminUser()` — that lookup uses
|
|
||||||
-- `permissionsForRole(role)` from `lib/admin-permissions.ts`, which
|
|
||||||
-- derives flags from the user's role rather than from these
|
|
||||||
-- columns. They are kept on the row so the create-user form's
|
|
||||||
-- per-user permission toggles can persist; they simply do not yet
|
|
||||||
-- affect runtime authorization. Cleanup target: either move them into
|
|
||||||
-- a per-user permission table that the role-derived lookup merges
|
|
||||||
-- in, or drop them from the form.
|
|
||||||
--
|
|
||||||
-- - `brand_id` is a denormalization of `admin_user_brands` (the link
|
|
||||||
-- table is the source of truth for brand assignment, and
|
|
||||||
-- `getAdminUser()` reads from it). The action writes both, which is
|
|
||||||
-- redundant but not incorrect — keeping it makes the action's
|
|
||||||
-- read-after-write (`getAdminUsers` joins on `au.brand_id`) work
|
|
||||||
-- without rewriting the SELECT.
|
|
||||||
--
|
|
||||||
-- - `must_change_password` defaults to FALSE at the column level; the
|
|
||||||
-- action sets it to TRUE on every create so the user is forced to
|
|
||||||
-- set a new password on first sign-in.
|
|
||||||
|
|
||||||
ALTER TABLE admin_users
|
|
||||||
ADD COLUMN IF NOT EXISTS display_name TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS phone_number TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
|
|
||||||
ADD COLUMN IF NOT EXISTS can_manage_pickup BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
ADD COLUMN IF NOT EXISTS can_manage_messages BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
ADD COLUMN IF NOT EXISTS can_manage_refunds BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
ADD COLUMN IF NOT EXISTS can_manage_users BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
ADD COLUMN IF NOT EXISTS auth_provider TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS auth_subject TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ;
|
|
||||||
|
|
||||||
-- Helpful index for the brand-name lookup the user list does
|
|
||||||
-- (LEFT JOIN brands b ON b.id = au.brand_id).
|
|
||||||
CREATE INDEX IF NOT EXISTS admin_users_brand_id_idx ON admin_users (brand_id);
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
-- 0090_water_log_completion.sql
|
|
||||||
--
|
|
||||||
-- Water Log feature completion. The original `0001_init.sql` shipped the
|
|
||||||
-- core five tables (headgates, irrigators, sessions, log entries, alerts)
|
|
||||||
-- with RLS, but the SaaS rebuild's actions and UIs depend on a wider
|
|
||||||
-- surface that this migration adds:
|
|
||||||
--
|
|
||||||
-- - `water_headgates.headgate_token` — opaque per-gate token for QR
|
|
||||||
-- - `water_headgates.status` — open / closed / maintenance
|
|
||||||
-- - `water_headgates.max_flow_gpm` — optional high-water marker
|
|
||||||
-- - `water_headgates.notes` — free-form location notes
|
|
||||||
-- - `water_headgates.last_used_at` — denormalized for display
|
|
||||||
-- - `water_headgates.high_threshold` — optional alert ceiling
|
|
||||||
-- - `water_headgates.low_threshold` — optional alert floor
|
|
||||||
-- - `water_headgates.unit` — display unit (CFS/GPM/etc.)
|
|
||||||
--
|
|
||||||
-- - `water_irrigators.role` — "irrigator" | "water_admin"
|
|
||||||
-- - `water_irrigators.phone` — optional contact
|
|
||||||
-- - `water_irrigators.notes` — free-form
|
|
||||||
--
|
|
||||||
-- - `water_log_entries.method` — "manual" | "meter" | "estimate" | "qr"
|
|
||||||
-- - `water_log_entries.total_gallons` — derived when computable
|
|
||||||
-- - `water_log_entries.photo_url` — link to storage object
|
|
||||||
-- - `water_log_entries.logged_date` — date-only (YYYY-MM-DD) for fast grouping
|
|
||||||
-- - `water_log_entries.latitude`/`longitude` — optional GPS pin
|
|
||||||
-- - `water_log_entries.brand_id` index upgrade
|
|
||||||
--
|
|
||||||
-- - NEW `water_admin_settings` (per-brand admin PIN + alert config)
|
|
||||||
-- - NEW `water_admin_sessions` (admin sign-in sessions, separate cookie)
|
|
||||||
-- - NEW `water_audit_log` (who changed what, when)
|
|
||||||
--
|
|
||||||
-- All new tables follow project conventions:
|
|
||||||
-- - TIMESTAMPTZ for timestamps
|
|
||||||
-- - UUID PKs via gen_random_uuid()
|
|
||||||
-- - brand_id scoped, RLS enabled, FORCE'd
|
|
||||||
-- - CREATE TABLE IF NOT EXISTS for re-runnability
|
|
||||||
--
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- ─── 1. Extend water_headgates ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
ALTER TABLE water_headgates
|
|
||||||
ADD COLUMN IF NOT EXISTS headgate_token TEXT UNIQUE
|
|
||||||
DEFAULT encode(gen_random_bytes(12), 'hex'),
|
|
||||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'
|
|
||||||
CHECK (status IN ('open', 'closed', 'maintenance')),
|
|
||||||
ADD COLUMN IF NOT EXISTS max_flow_gpm NUMERIC,
|
|
||||||
ADD COLUMN IF NOT EXISTS notes TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS last_used_at TIMESTAMPTZ,
|
|
||||||
ADD COLUMN IF NOT EXISTS high_threshold NUMERIC,
|
|
||||||
ADD COLUMN IF NOT EXISTS low_threshold NUMERIC,
|
|
||||||
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS';
|
|
||||||
|
|
||||||
-- Backfill any existing rows that pre-date the default expression
|
|
||||||
UPDATE water_headgates
|
|
||||||
SET headgate_token = encode(gen_random_bytes(12), 'hex')
|
|
||||||
WHERE headgate_token IS NULL;
|
|
||||||
|
|
||||||
-- ─── 2. Extend water_irrigators ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
ALTER TABLE water_irrigators
|
|
||||||
ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'irrigator'
|
|
||||||
CHECK (role IN ('irrigator', 'water_admin')),
|
|
||||||
ADD COLUMN IF NOT EXISTS phone TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS notes TEXT;
|
|
||||||
|
|
||||||
-- ─── 3. Extend water_log_entries ────────────────────────────────────────────
|
|
||||||
|
|
||||||
ALTER TABLE water_log_entries
|
|
||||||
ADD COLUMN IF NOT EXISTS method TEXT NOT NULL DEFAULT 'manual'
|
|
||||||
CHECK (method IN ('manual', 'meter', 'estimate', 'qr')),
|
|
||||||
ADD COLUMN IF NOT EXISTS total_gallons NUMERIC,
|
|
||||||
ADD COLUMN IF NOT EXISTS photo_url TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS logged_date DATE,
|
|
||||||
ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION,
|
|
||||||
ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION;
|
|
||||||
|
|
||||||
-- Backfill logged_date for any rows that pre-date the column
|
|
||||||
UPDATE water_log_entries
|
|
||||||
SET logged_date = (logged_at AT TIME ZONE 'UTC')::date
|
|
||||||
WHERE logged_date IS NULL AND logged_at IS NOT NULL;
|
|
||||||
|
|
||||||
-- Index for fast "today's entries" + "this week's entries" queries
|
|
||||||
CREATE INDEX IF NOT EXISTS water_log_entries_brand_date_idx
|
|
||||||
ON water_log_entries (brand_id, logged_date DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS water_log_entries_irrigator_idx
|
|
||||||
ON water_log_entries (irrigator_id, logged_at DESC);
|
|
||||||
|
|
||||||
-- ─── 4. water_admin_settings (per brand) ───────────────────────────────────
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS water_admin_settings (
|
|
||||||
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
session_duration_hours INTEGER NOT NULL DEFAULT 4
|
|
||||||
CHECK (session_duration_hours BETWEEN 1 AND 168),
|
|
||||||
can_edit_entries BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
can_delete_entries BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
can_export_csv BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
alert_phone TEXT,
|
|
||||||
alerts_enabled BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_by UUID REFERENCES admin_users(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_trigger t
|
|
||||||
JOIN pg_class c ON t.tgrelid = c.oid
|
|
||||||
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
||||||
WHERE t.tgname = 'water_admin_settings_updated_at' AND n.nspname = current_schema()
|
|
||||||
) THEN
|
|
||||||
CREATE TRIGGER water_admin_settings_updated_at BEFORE UPDATE ON water_admin_settings
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ─── 5. water_admin_sessions (separate from irrigator sessions) ───────────
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS water_admin_sessions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
|
||||||
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
|
||||||
pin_hash_used TEXT NOT NULL,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS water_admin_sessions_admin_idx
|
|
||||||
ON water_admin_sessions (admin_user_id, expires_at DESC);
|
|
||||||
|
|
||||||
-- ─── 6. water_audit_log (who changed what, when) ───────────────────────────
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS water_audit_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
|
||||||
actor_id UUID REFERENCES admin_users(id),
|
|
||||||
actor_label TEXT NOT NULL,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
entity_type TEXT NOT NULL,
|
|
||||||
entity_id UUID,
|
|
||||||
details JSONB,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS water_audit_log_brand_recent_idx
|
|
||||||
ON water_audit_log (brand_id, created_at DESC);
|
|
||||||
|
|
||||||
-- ─── 7. RLS for new tables ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
ALTER TABLE water_admin_settings ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE water_admin_sessions ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE water_audit_log ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
ALTER TABLE water_admin_settings FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE water_admin_sessions FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE water_audit_log FORCE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation ON water_admin_settings;
|
|
||||||
CREATE POLICY tenant_isolation ON water_admin_settings FOR ALL
|
|
||||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
|
||||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation ON water_admin_sessions;
|
|
||||||
CREATE POLICY tenant_isolation ON water_admin_sessions FOR ALL
|
|
||||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
|
||||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation ON water_audit_log;
|
|
||||||
CREATE POLICY tenant_isolation ON water_audit_log FOR ALL
|
|
||||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
|
||||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Audit log. Source of truth: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
jsonb,
|
|
||||||
timestamp,
|
|
||||||
index,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands } from "./brands";
|
|
||||||
|
|
||||||
export const auditLog = pgTable(
|
|
||||||
"audit_log",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id").references(() => brands.id, {
|
|
||||||
onDelete: "cascade",
|
|
||||||
}),
|
|
||||||
userId: uuid("user_id"), // FK to neon_auth.user(id) — plain UUID, enforced at DB level
|
|
||||||
action: text("action").notNull(),
|
|
||||||
targetType: text("target_type"),
|
|
||||||
targetId: uuid("target_id"),
|
|
||||||
payload: jsonb("payload"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("audit_log_brand_idx").on(t.brandId, t.createdAt),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type AuditLog = typeof auditLog.$inferSelect;
|
|
||||||
export type NewAuditLog = typeof auditLog.$inferInsert;
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
/**
|
|
||||||
* Billing: plans, add_ons, brand_add_ons.
|
|
||||||
* Source: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
integer,
|
|
||||||
timestamp,
|
|
||||||
jsonb,
|
|
||||||
primaryKey,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands } from "./brands";
|
|
||||||
|
|
||||||
export const plans = pgTable("plans", {
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
code: text("code", {
|
|
||||||
enum: ["starter", "farm", "enterprise"],
|
|
||||||
}).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: [
|
|
||||||
"wholesale_portal", "harvest_reach", "ai_tools",
|
|
||||||
"water_log", "square_sync", "sms_campaigns",
|
|
||||||
],
|
|
||||||
}).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 brandAddOns = pgTable(
|
|
||||||
"brand_add_ons",
|
|
||||||
{
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
addOnId: uuid("add_on_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => addOns.id, { onDelete: "cascade" }),
|
|
||||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
|
||||||
status: text("status", { enum: ["active", "canceled"] })
|
|
||||||
.notNull()
|
|
||||||
.default("active"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
pk: primaryKey({ columns: [t.brandId, t.addOnId] }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Plan = typeof plans.$inferSelect;
|
|
||||||
export type AddOn = typeof addOns.$inferSelect;
|
|
||||||
export type BrandAddOn = typeof brandAddOns.$inferSelect;
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* Brand settings + brand features. Source: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
jsonb,
|
|
||||||
boolean,
|
|
||||||
timestamp,
|
|
||||||
uniqueIndex,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands } from "./brands";
|
|
||||||
|
|
||||||
export const brandSettings = pgTable(
|
|
||||||
"brand_settings",
|
|
||||||
{
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.primaryKey()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
legalBusinessName: text("legal_business_name"),
|
|
||||||
phone: text("phone"),
|
|
||||||
email: text("email"),
|
|
||||||
websiteUrl: text("website_url"),
|
|
||||||
streetAddress: text("street_address"),
|
|
||||||
city: text("city"),
|
|
||||||
state: text("state"),
|
|
||||||
postalCode: text("postal_code"),
|
|
||||||
country: text("country").default("US"),
|
|
||||||
logoUrl: text("logo_url"),
|
|
||||||
logoUrlDark: text("logo_url_dark"),
|
|
||||||
heroImageUrl: text("hero_image_url"),
|
|
||||||
tagline: text("tagline"),
|
|
||||||
aboutHtml: text("about_html"),
|
|
||||||
primaryColor: text("primary_color").default("#0F766E"),
|
|
||||||
defaultEmailSignature: text("default_email_signature"),
|
|
||||||
invoiceFooterNotes: text("invoice_footer_notes"),
|
|
||||||
fromEmail: text("from_email"),
|
|
||||||
fromName: text("from_name"),
|
|
||||||
replyToEmail: text("reply_to_email"),
|
|
||||||
customFooterText: text("custom_footer_text"),
|
|
||||||
featureFlags: jsonb("feature_flags").notNull().default({}),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const brandFeatures = pgTable(
|
|
||||||
"brand_features",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
featureKey: text("feature_key").notNull(),
|
|
||||||
enabled: boolean("enabled").notNull().default(false),
|
|
||||||
enabledAt: timestamp("enabled_at", { withTimezone: true }),
|
|
||||||
disabledAt: timestamp("disabled_at", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandFeatureIdx: uniqueIndex("brand_features_brand_key_idx").on(
|
|
||||||
t.brandId,
|
|
||||||
t.featureKey,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type BrandSettings = typeof brandSettings.$inferSelect;
|
|
||||||
export type BrandFeature = typeof brandFeatures.$inferSelect;
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
/**
|
|
||||||
* Brands (Tenants). Source of truth: `db/migrations/0001_init.sql`.
|
|
||||||
* Multi-brand isolation: every business table has `brand_id` FK → brands.id.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
integer,
|
|
||||||
boolean,
|
|
||||||
timestamp,
|
|
||||||
index,
|
|
||||||
uniqueIndex,
|
|
||||||
primaryKey,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
|
|
||||||
export const brands = pgTable(
|
|
||||||
"brands",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
slug: text("slug").notNull().unique(),
|
|
||||||
planTier: text("plan_tier", {
|
|
||||||
enum: ["starter", "farm", "enterprise"],
|
|
||||||
}).notNull().default("starter"),
|
|
||||||
maxUsers: integer("max_users").notNull().default(2),
|
|
||||||
maxProducts: integer("max_products").notNull().default(25),
|
|
||||||
maxStopsMonthly: integer("max_stops_monthly").notNull().default(10),
|
|
||||||
stripeCustomerId: text("stripe_customer_id"),
|
|
||||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
|
||||||
stripeSubscriptionStatus: text("stripe_subscription_status", {
|
|
||||||
enum: [
|
|
||||||
"trialing", "active", "past_due", "canceled",
|
|
||||||
"incomplete", "incomplete_expired",
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
stripeCurrentPeriodEnd: timestamp("stripe_current_period_end", {
|
|
||||||
withTimezone: true,
|
|
||||||
}),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
slugIdx: uniqueIndex("brands_slug_idx").on(t.slug),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Admin Users ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const adminUsers = pgTable(
|
|
||||||
"admin_users",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
// FK to neon_auth.user(id) is enforced at DB level by the migration SQL.
|
|
||||||
// Drizzle can't reference tables in other schemas, so no .references() here.
|
|
||||||
userId: uuid("user_id"),
|
|
||||||
email: text("email").notNull().unique(),
|
|
||||||
name: text("name"),
|
|
||||||
// The columns below were added in migration 0043. They back the
|
|
||||||
// create-user / list-users / edit-user flows in
|
|
||||||
// `src/actions/admin/users.ts`. See that migration's header for
|
|
||||||
// notes on which of these are vestigial (the four can_manage_*
|
|
||||||
// toggles) vs. read by the UI.
|
|
||||||
displayName: text("display_name"),
|
|
||||||
phoneNumber: text("phone_number"),
|
|
||||||
brandId: uuid("brand_id"),
|
|
||||||
canManagePickup: boolean("can_manage_pickup").notNull().default(true),
|
|
||||||
canManageMessages: boolean("can_manage_messages").notNull().default(true),
|
|
||||||
canManageRefunds: boolean("can_manage_refunds").notNull().default(false),
|
|
||||||
canManageUsers: boolean("can_manage_users").notNull().default(false),
|
|
||||||
active: boolean("active").notNull().default(true),
|
|
||||||
mustChangePassword: boolean("must_change_password").notNull().default(false),
|
|
||||||
authProvider: text("auth_provider"),
|
|
||||||
authSubject: text("auth_subject"),
|
|
||||||
lastLogin: timestamp("last_login", { withTimezone: true }),
|
|
||||||
role: text("role", {
|
|
||||||
enum: ["platform_admin", "brand_admin", "store_employee"],
|
|
||||||
}).notNull().default("brand_admin"),
|
|
||||||
canManageOrders: boolean("can_manage_orders").notNull().default(true),
|
|
||||||
canManageProducts: boolean("can_manage_products").notNull().default(true),
|
|
||||||
canManageStops: boolean("can_manage_stops").notNull().default(true),
|
|
||||||
canManageCustomers: boolean("can_manage_customers").notNull().default(true),
|
|
||||||
canManageWholesale: boolean("can_manage_wholesale").notNull().default(false),
|
|
||||||
canManageBilling: boolean("can_manage_billing").notNull().default(false),
|
|
||||||
canManageSettings: boolean("can_manage_settings").notNull().default(false),
|
|
||||||
canManageWaterLog: boolean("can_manage_water_log").notNull().default(false),
|
|
||||||
canManageTimeTracking: boolean("can_manage_time_tracking").notNull().default(false),
|
|
||||||
canManageRouteTrace: boolean("can_manage_route_trace").notNull().default(false),
|
|
||||||
canManageReports: boolean("can_manage_reports").notNull().default(true),
|
|
||||||
canManageCommunications: boolean("can_manage_communications").notNull().default(false),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
userIdIdx: uniqueIndex("admin_users_user_id_idx").on(t.userId),
|
|
||||||
emailIdx: uniqueIndex("admin_users_email_idx").on(t.email),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const adminUserBrands = pgTable(
|
|
||||||
"admin_user_brands",
|
|
||||||
{
|
|
||||||
adminUserId: uuid("admin_user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => adminUsers.id, { onDelete: "cascade" }),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
addedAt: timestamp("added_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
addedBy: uuid("added_by").references(() => adminUsers.id),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
pk: primaryKey({ columns: [t.adminUserId, t.brandId] }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Neon Auth stub (for Drizzle typing only — table is managed by Neon Auth) ──
|
|
||||||
// The actual table is neon_auth.user (singular). We can't FK-reference a table
|
|
||||||
// in another schema via Drizzle, so we store userId as a plain UUID and rely on
|
|
||||||
// the DB-level FK constraint (enforced by the migration SQL).
|
|
||||||
export const authUsers = pgTable("user", {
|
|
||||||
id: uuid("id").primaryKey(),
|
|
||||||
name: text("name"),
|
|
||||||
email: text("email"),
|
|
||||||
emailVerified: boolean("email_verified"),
|
|
||||||
image: text("image"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Brand = typeof brands.$inferSelect;
|
|
||||||
export type NewBrand = typeof brands.$inferInsert;
|
|
||||||
export type AdminUser = typeof adminUsers.$inferSelect;
|
|
||||||
export type NewAdminUser = typeof adminUsers.$inferInsert;
|
|
||||||
export type AdminUserBrand = typeof adminUserBrands.$inferSelect;
|
|
||||||
export type NewAdminUserBrand = typeof adminUserBrands.$inferInsert;
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
/**
|
|
||||||
* Communications (Harvest Reach).
|
|
||||||
* Source: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
integer,
|
|
||||||
boolean,
|
|
||||||
timestamp,
|
|
||||||
jsonb,
|
|
||||||
index,
|
|
||||||
uniqueIndex,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands, adminUsers } from "./brands";
|
|
||||||
|
|
||||||
export const communicationSettings = pgTable(
|
|
||||||
"communication_settings",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.unique()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
defaultSenderEmail: text("default_sender_email"),
|
|
||||||
defaultSenderName: text("default_sender_name"),
|
|
||||||
replyToEmail: text("reply_to_email"),
|
|
||||||
emailProvider: text("email_provider").notNull().default("resend"),
|
|
||||||
emailFooterHtml: text("email_footer_html"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const communicationTemplates = pgTable(
|
|
||||||
"communication_templates",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
subject: text("subject").notNull(),
|
|
||||||
bodyText: text("body_text").notNull().default(""),
|
|
||||||
bodyHtml: text("body_html"),
|
|
||||||
templateType: text("template_type").notNull(),
|
|
||||||
campaignType: text("campaign_type"),
|
|
||||||
createdBy: uuid("created_by"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("communication_templates_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const communicationSegments = pgTable(
|
|
||||||
"communication_segments",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
description: text("description"),
|
|
||||||
rules: jsonb("rules").notNull().default({}),
|
|
||||||
createdBy: uuid("created_by"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("communication_segments_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const communicationCampaigns = pgTable(
|
|
||||||
"communication_campaigns",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
subject: text("subject"),
|
|
||||||
bodyText: text("body_text"),
|
|
||||||
bodyHtml: text("body_html"),
|
|
||||||
templateId: uuid("template_id").references(
|
|
||||||
() => communicationTemplates.id,
|
|
||||||
{ onDelete: "set null" },
|
|
||||||
),
|
|
||||||
campaignType: text("campaign_type").notNull(),
|
|
||||||
status: text("status", {
|
|
||||||
enum: ["draft", "scheduled", "sending", "sent", "canceled"],
|
|
||||||
}).notNull().default("draft"),
|
|
||||||
audienceRules: jsonb("audience_rules").notNull().default({}),
|
|
||||||
brandName: text("brand_name"),
|
|
||||||
scheduledAt: timestamp("scheduled_at", { withTimezone: true }),
|
|
||||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
|
||||||
recipientCount: integer("recipient_count").notNull().default(0),
|
|
||||||
createdBy: uuid("created_by"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("communication_campaigns_brand_idx").on(t.brandId),
|
|
||||||
statusIdx: index("communication_campaigns_status_idx").on(
|
|
||||||
t.brandId,
|
|
||||||
t.status,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const communicationContacts = pgTable(
|
|
||||||
"communication_contacts",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
email: text("email"),
|
|
||||||
phone: text("phone"),
|
|
||||||
firstName: text("first_name"),
|
|
||||||
lastName: text("last_name"),
|
|
||||||
fullName: text("full_name"),
|
|
||||||
source: text("source").notNull(),
|
|
||||||
externalId: text("external_id"),
|
|
||||||
customerId: uuid("customer_id"),
|
|
||||||
emailOptIn: boolean("email_opt_in").notNull().default(true),
|
|
||||||
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
|
||||||
emailOptInAt: timestamp("email_opt_in_at", { withTimezone: true }),
|
|
||||||
smsOptInAt: timestamp("sms_opt_in_at", { withTimezone: true }),
|
|
||||||
unsubscribedAt: timestamp("unsubscribed_at", { withTimezone: true }),
|
|
||||||
tags: text("tags").array().default([]),
|
|
||||||
metadata: jsonb("metadata").default({}),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("communication_contacts_brand_idx").on(t.brandId),
|
|
||||||
emailIdx: index("communication_contacts_email_idx").on(
|
|
||||||
t.brandId,
|
|
||||||
t.email,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const communicationMessageLogs = pgTable(
|
|
||||||
"communication_message_logs",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
campaignId: uuid("campaign_id").references(
|
|
||||||
() => communicationCampaigns.id,
|
|
||||||
{ onDelete: "set null" },
|
|
||||||
),
|
|
||||||
contactId: uuid("contact_id").references(
|
|
||||||
() => communicationContacts.id,
|
|
||||||
{ onDelete: "set null" },
|
|
||||||
),
|
|
||||||
customerEmail: text("customer_email"),
|
|
||||||
deliveryMethod: text("delivery_method").notNull(),
|
|
||||||
subject: text("subject"),
|
|
||||||
bodyPreview: text("body_preview"),
|
|
||||||
status: text("status").notNull(),
|
|
||||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
|
||||||
errorMessage: text("error_message"),
|
|
||||||
eventType: text("event_type"),
|
|
||||||
eventId: uuid("event_id"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("communication_message_logs_brand_idx").on(t.brandId),
|
|
||||||
campaignIdx: index(
|
|
||||||
"communication_message_logs_campaign_idx",
|
|
||||||
).on(t.campaignId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const customerCommunicationPreferences = pgTable(
|
|
||||||
"customer_communication_preferences",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
customerId: uuid("customer_id").notNull(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
emailOptIn: boolean("email_opt_in").notNull().default(true),
|
|
||||||
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
|
||||||
emailOptInAt: timestamp("email_opt_in_at", { withTimezone: true }),
|
|
||||||
smsOptInAt: timestamp("sms_opt_in_at", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
customerBrandIdx: uniqueIndex(
|
|
||||||
"customer_communication_prefs_customer_brand_idx",
|
|
||||||
).on(t.customerId, t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Email automation sequences
|
|
||||||
export const abandonedCartRecovery = pgTable(
|
|
||||||
"abandoned_cart_recovery",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
customerId: uuid("customer_id"),
|
|
||||||
contactEmail: text("contact_email").notNull(),
|
|
||||||
contactName: text("contact_name"),
|
|
||||||
cartSnapshot: jsonb("cart_snapshot").notNull(),
|
|
||||||
brandName: text("brand_name"),
|
|
||||||
locale: text("locale").default("en"),
|
|
||||||
sequenceStep: integer("sequence_step").default(0),
|
|
||||||
lastEmailSentAt: timestamp("last_email_sent_at", {
|
|
||||||
withTimezone: true,
|
|
||||||
}),
|
|
||||||
nextEmailAt: timestamp("next_email_at", { withTimezone: true }),
|
|
||||||
status: text("status", {
|
|
||||||
enum: ["active", "recovered", "expired", "manually_closed"],
|
|
||||||
}).default("active"),
|
|
||||||
recoveredOrderId: uuid("recovered_order_id"),
|
|
||||||
recoveredAt: timestamp("recovered_at", { withTimezone: true }),
|
|
||||||
expiredAt: timestamp("expired_at", { withTimezone: true }),
|
|
||||||
manuallyClosedAt: timestamp("manually_closed_at", {
|
|
||||||
withTimezone: true,
|
|
||||||
}),
|
|
||||||
manuallyClosedBy: uuid("manually_closed_by").references(
|
|
||||||
() => adminUsers.id,
|
|
||||||
),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const welcomeEmailSequence = pgTable(
|
|
||||||
"welcome_email_sequence",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
contactId: uuid("contact_id").references(
|
|
||||||
() => communicationContacts.id,
|
|
||||||
{ onDelete: "cascade" },
|
|
||||||
),
|
|
||||||
contactEmail: text("contact_email").notNull(),
|
|
||||||
contactName: text("contact_name"),
|
|
||||||
brandName: text("brand_name"),
|
|
||||||
locale: text("locale").default("en"),
|
|
||||||
sequenceStep: integer("sequence_step").default(0),
|
|
||||||
lastEmailSentAt: timestamp("last_email_sent_at", {
|
|
||||||
withTimezone: true,
|
|
||||||
}),
|
|
||||||
nextEmailAt: timestamp("next_email_at", { withTimezone: true }),
|
|
||||||
status: text("status", {
|
|
||||||
enum: ["active", "completed", "unsubscribed", "bounced"],
|
|
||||||
}).default("active"),
|
|
||||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
|
||||||
unsubscribedAt: timestamp("unsubscribed_at", { withTimezone: true }),
|
|
||||||
bouncedAt: timestamp("bounced_at", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type CommunicationSettings =
|
|
||||||
typeof communicationSettings.$inferSelect;
|
|
||||||
export type CommunicationTemplate = typeof communicationTemplates.$inferSelect;
|
|
||||||
export type CommunicationSegment = typeof communicationSegments.$inferSelect;
|
|
||||||
export type CommunicationCampaign = typeof communicationCampaigns.$inferSelect;
|
|
||||||
export type CommunicationContact = typeof communicationContacts.$inferSelect;
|
|
||||||
export type CommunicationMessageLog = typeof communicationMessageLogs.$inferSelect;
|
|
||||||
export type CustomerCommunicationPreference =
|
|
||||||
typeof customerCommunicationPreferences.$inferSelect;
|
|
||||||
export type AbandonedCartRecovery = typeof abandonedCartRecovery.$inferSelect;
|
|
||||||
export type WelcomeEmailSequence = typeof welcomeEmailSequence.$inferSelect;
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* Customers. Source: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
boolean,
|
|
||||||
timestamp,
|
|
||||||
varchar,
|
|
||||||
bigint,
|
|
||||||
jsonb,
|
|
||||||
index,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands } from "./brands";
|
|
||||||
|
|
||||||
export const customers = pgTable(
|
|
||||||
"customers",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
email: text("email"),
|
|
||||||
phone: text("phone"),
|
|
||||||
firstName: text("first_name"),
|
|
||||||
lastName: text("last_name"),
|
|
||||||
fullName: text("full_name"),
|
|
||||||
source: text("source").notNull().default("system"),
|
|
||||||
externalId: text("external_id"),
|
|
||||||
customerId: uuid("customer_id"),
|
|
||||||
emailOptIn: boolean("email_opt_in").notNull().default(true),
|
|
||||||
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
|
||||||
emailOptInAt: timestamp("email_opt_in_at", { withTimezone: true }),
|
|
||||||
smsOptInAt: timestamp("sms_opt_in_at", { withTimezone: true }),
|
|
||||||
unsubscribedAt: timestamp("unsubscribed_at", { withTimezone: true }),
|
|
||||||
tags: text("tags").array().notNull().default([]),
|
|
||||||
metadata: jsonb("metadata").notNull().default({}),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("customers_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Customer = typeof customers.$inferSelect;
|
|
||||||
export type NewCustomer = typeof customers.$inferInsert;
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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];
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Files. Source of truth: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
integer,
|
|
||||||
timestamp,
|
|
||||||
index,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands } from "./brands";
|
|
||||||
|
|
||||||
export const files = pgTable(
|
|
||||||
"files",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id").references(() => brands.id, {
|
|
||||||
onDelete: "cascade",
|
|
||||||
}),
|
|
||||||
storageKey: text("storage_key").notNull().unique(),
|
|
||||||
mimeType: text("mime_type").notNull(),
|
|
||||||
sizeBytes: integer("size_bytes").notNull(),
|
|
||||||
purpose: text("purpose"),
|
|
||||||
uploadedBy: uuid("uploaded_by"), // FK to neon_auth.user(id) — plain UUID, enforced at DB level
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("files_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type File = typeof files.$inferSelect;
|
|
||||||
export type NewFile = typeof files.$inferInsert;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* Schema barrel. Re-exports every Drizzle table + inferred row type.
|
|
||||||
* Source of truth: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
export * from "./brands";
|
|
||||||
export * from "./billing";
|
|
||||||
export * from "./products";
|
|
||||||
export * from "./stops";
|
|
||||||
export * from "./customers";
|
|
||||||
export * from "./orders";
|
|
||||||
export * from "./brand";
|
|
||||||
export * from "./wholesale";
|
|
||||||
export * from "./water-log";
|
|
||||||
export * from "./communications";
|
|
||||||
export * from "./marketing";
|
|
||||||
export * from "./time-tracking";
|
|
||||||
export * from "./shipping";
|
|
||||||
export * from "./support";
|
|
||||||
export * from "./files";
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { brands } from "./brands";
|
|
||||||
|
|
||||||
export const emailTemplates = pgTable(
|
|
||||||
"email_templates",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.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) => ({
|
|
||||||
brandIdx: index("email_templates_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const campaigns = pgTable(
|
|
||||||
"campaigns",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.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) => ({
|
|
||||||
brandIdx: index("campaigns_brand_idx").on(t.brandId),
|
|
||||||
statusIdx: index("campaigns_status_idx").on(t.brandId, 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;
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* Orders + order_items. Source: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
integer,
|
|
||||||
timestamp,
|
|
||||||
index,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands } from "./brands";
|
|
||||||
import { customers } from "./customers";
|
|
||||||
import { stops } from "./stops";
|
|
||||||
import { products } from "./products";
|
|
||||||
|
|
||||||
export const orders = pgTable(
|
|
||||||
"orders",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
customerId: uuid("customer_id").references(() => customers.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
stopId: uuid("stop_id").references(() => stops.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
totalCents: integer("total_cents").notNull().default(0),
|
|
||||||
status: text("status", {
|
|
||||||
enum: ["pending", "confirmed", "fulfilled", "canceled"],
|
|
||||||
}).notNull().default("pending"),
|
|
||||||
fulfillment: text("fulfillment", {
|
|
||||||
enum: ["pickup", "ship", "mixed"],
|
|
||||||
}).notNull(),
|
|
||||||
customerAddress: text("customer_address"),
|
|
||||||
customerCity: text("customer_city"),
|
|
||||||
customerState: text("customer_state"),
|
|
||||||
customerZip: text("customer_zip"),
|
|
||||||
idempotencyKey: text("idempotency_key"),
|
|
||||||
notes: text("notes"),
|
|
||||||
placedAt: timestamp("placed_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("orders_brand_idx").on(t.brandId),
|
|
||||||
statusIdx: index("orders_status_idx").on(t.brandId, t.status),
|
|
||||||
stopIdx: index("orders_stop_idx").on(t.stopId),
|
|
||||||
customerIdx: index("orders_customer_idx").on(t.customerId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
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: ["pickup", "ship"],
|
|
||||||
}).notNull(),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(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;
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* Products. Source: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
integer,
|
|
||||||
boolean,
|
|
||||||
timestamp,
|
|
||||||
index,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands } from "./brands";
|
|
||||||
|
|
||||||
export const products = pgTable(
|
|
||||||
"products",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
description: text("description"),
|
|
||||||
sku: text("sku"),
|
|
||||||
type: text("type", { enum: ["standard", "wholesale", "both"] })
|
|
||||||
.notNull()
|
|
||||||
.default("standard"),
|
|
||||||
priceCents: integer("price_cents").notNull(),
|
|
||||||
inventory: integer("inventory").notNull().default(0),
|
|
||||||
unit: text("unit"),
|
|
||||||
active: boolean("active").notNull().default(true),
|
|
||||||
isTaxable: boolean("is_taxable").notNull().default(false),
|
|
||||||
pickupType: text("pickup_type", { enum: ["pickup", "ship", "all"] })
|
|
||||||
.notNull()
|
|
||||||
.default("all"),
|
|
||||||
imageUrl: text("image_url"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("products_brand_idx").on(t.brandId),
|
|
||||||
activeIdx: index("products_active_idx").on(t.brandId, t.active),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Product = typeof products.$inferSelect;
|
|
||||||
export type NewProduct = typeof products.$inferInsert;
|
|
||||||
|
|
||||||
// ── Product Images ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ProductImage = typeof productImages.$inferSelect;
|
|
||||||
export type NewProductImage = typeof productImages.$inferInsert;
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shipping + Payments. Source: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
numeric,
|
|
||||||
boolean,
|
|
||||||
date,
|
|
||||||
timestamp,
|
|
||||||
index,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands } from "./brands";
|
|
||||||
import { orders } from "./orders";
|
|
||||||
|
|
||||||
export const shippingSettings = pgTable(
|
|
||||||
"shipping_settings",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.unique()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
carrier: text("carrier").notNull().default("fedex"),
|
|
||||||
fedexAccountNumber: text("fedex_account_number"),
|
|
||||||
fedexApiKey: text("fedex_api_key"),
|
|
||||||
fedexApiSecret: text("fedex_api_secret"),
|
|
||||||
fedexUseProduction: boolean("fedex_use_production")
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
defaultServiceType: text("default_service_type")
|
|
||||||
.notNull()
|
|
||||||
.default("FEDEX_GROUND"),
|
|
||||||
refrigeratedHandlingNotes: text("refrigerated_handling_notes"),
|
|
||||||
fragileHandlingNotes: text("fragile_handling_notes"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const shipments = pgTable(
|
|
||||||
"shipments",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
orderId: uuid("order_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => orders.id, { onDelete: "cascade" }),
|
|
||||||
carrier: text("carrier").notNull().default("fedex"),
|
|
||||||
serviceType: text("service_type").notNull(),
|
|
||||||
trackingNumber: text("tracking_number"),
|
|
||||||
labelUrl: text("label_url"),
|
|
||||||
rateCharged: numeric("rate_charged", { precision: 10, scale: 2 }),
|
|
||||||
estimatedDeliveryDate: date("estimated_delivery_date"),
|
|
||||||
isRefrigerated: boolean("is_refrigerated").notNull().default(false),
|
|
||||||
isFragile: boolean("is_fragile").notNull().default(false),
|
|
||||||
handlingNotes: text("handling_notes"),
|
|
||||||
status: text("status", {
|
|
||||||
enum: [
|
|
||||||
"created", "label_printed", "picked_up",
|
|
||||||
"in_transit", "delivered", "exception",
|
|
||||||
],
|
|
||||||
}).notNull().default("created"),
|
|
||||||
fedexShipmentId: text("fedex_shipment_id"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
createdBy: uuid("created_by"),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
orderIdx: index("shipments_order_idx").on(t.orderId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const paymentSettings = pgTable(
|
|
||||||
"payment_settings",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.unique()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
provider: text("provider"),
|
|
||||||
stripePublishableKey: text("stripe_publishable_key"),
|
|
||||||
stripeSecretKey: text("stripe_secret_key"),
|
|
||||||
squareAccessToken: text("square_access_token"),
|
|
||||||
squareLocationId: text("square_location_id"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ShippingSettings = typeof shippingSettings.$inferSelect;
|
|
||||||
export type Shipment = typeof shipments.$inferSelect;
|
|
||||||
export type PaymentSettings = typeof paymentSettings.$inferSelect;
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* Stops + Locations. Source: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
date,
|
|
||||||
boolean,
|
|
||||||
timestamp,
|
|
||||||
index,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands } from "./brands";
|
|
||||||
|
|
||||||
export const stops = pgTable(
|
|
||||||
"stops",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
location: text("location").notNull(),
|
|
||||||
address: text("address"),
|
|
||||||
city: text("city"),
|
|
||||||
state: text("state"),
|
|
||||||
zip: text("zip"),
|
|
||||||
date: date("date").notNull(),
|
|
||||||
// "time" is a reserved word — quoted in SQL, unquoted in JS
|
|
||||||
|
|
||||||
time: text("time"),
|
|
||||||
cutoffDate: date("cutoff_date"),
|
|
||||||
status: text("status", { enum: ["active", "paused", "closed"] })
|
|
||||||
.notNull()
|
|
||||||
.default("active"),
|
|
||||||
isPublic: boolean("is_public").notNull().default(true),
|
|
||||||
notes: text("notes"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("stops_brand_idx").on(t.brandId),
|
|
||||||
statusIdx: index("stops_status_idx").on(t.brandId, t.status),
|
|
||||||
dateIdx: index("stops_date_idx").on(t.brandId, t.date),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const locations = pgTable(
|
|
||||||
"locations",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
address: text("address"),
|
|
||||||
city: text("city"),
|
|
||||||
state: text("state"),
|
|
||||||
zip: text("zip"),
|
|
||||||
phone: text("phone"),
|
|
||||||
contactName: text("contact_name"),
|
|
||||||
contactEmail: text("contact_email"),
|
|
||||||
notes: text("notes"),
|
|
||||||
active: boolean("active").notNull().default(true),
|
|
||||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("locations_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Stop = typeof stops.$inferSelect;
|
|
||||||
export type NewStop = typeof stops.$inferInsert;
|
|
||||||
export type Location = typeof locations.$inferSelect;
|
|
||||||
export type NewLocation = typeof locations.$inferInsert;
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
/**
|
|
||||||
* Support tables: referrals, changelogs, onboarding, api_keys,
|
|
||||||
* notifications, operational events, audit logs.
|
|
||||||
* Source: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
varchar,
|
|
||||||
boolean,
|
|
||||||
timestamp,
|
|
||||||
jsonb,
|
|
||||||
integer,
|
|
||||||
numeric,
|
|
||||||
time,
|
|
||||||
index,
|
|
||||||
uniqueIndex,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands, adminUsers } from "./brands";
|
|
||||||
|
|
||||||
// ── Referrals ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const referralCodes = pgTable(
|
|
||||||
"referral_codes",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id").references(() => brands.id, {
|
|
||||||
onDelete: "cascade",
|
|
||||||
}),
|
|
||||||
referrerUserId: uuid("referrer_user_id").notNull(),
|
|
||||||
referralCode: varchar("referral_code", { length: 50 }).notNull().unique(),
|
|
||||||
referrerEmail: varchar("referrer_email", { length: 255 }).notNull(),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
||||||
maxUses: integer("max_uses").default(1),
|
|
||||||
currentUses: integer("current_uses").default(0),
|
|
||||||
isActive: boolean("is_active").default(true),
|
|
||||||
rewardType: varchar("reward_type", { length: 50 }).default("percentage"),
|
|
||||||
rewardValue: numeric("reward_value", { precision: 10, scale: 2 }).default("20.00"),
|
|
||||||
metadata: jsonb("metadata").default({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const referralRedemptions = pgTable(
|
|
||||||
"referral_redemptions",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
referralCodeId: uuid("referral_code_id").references(
|
|
||||||
() => referralCodes.id,
|
|
||||||
{ onDelete: "cascade" },
|
|
||||||
),
|
|
||||||
brandId: uuid("brand_id").references(() => brands.id, {
|
|
||||||
onDelete: "cascade",
|
|
||||||
}),
|
|
||||||
referredUserId: uuid("referred_user_id").notNull(),
|
|
||||||
referredEmail: varchar("referred_email", { length: 255 }).notNull(),
|
|
||||||
redeemedAt: timestamp("redeemed_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
rewardType: varchar("reward_type", { length: 50 }),
|
|
||||||
rewardValue: numeric("reward_value", { precision: 10, scale: 2 }),
|
|
||||||
isCreditApplied: boolean("is_credit_applied").default(false),
|
|
||||||
signupPlan: varchar("signup_plan", { length: 50 }),
|
|
||||||
signupValue: numeric("signup_value", { precision: 10, scale: 2 }),
|
|
||||||
metadata: jsonb("metadata").default({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Changelogs ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const changelogs = pgTable(
|
|
||||||
"changelogs",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id").references(() => brands.id, {
|
|
||||||
onDelete: "cascade",
|
|
||||||
}),
|
|
||||||
version: varchar("version", { length: 50 }).notNull(),
|
|
||||||
title: varchar("title", { length: 255 }).notNull(),
|
|
||||||
description: text("description"),
|
|
||||||
content: jsonb("content").notNull().default([]),
|
|
||||||
releasedAt: timestamp("released_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
isPublished: boolean("is_published").default(false),
|
|
||||||
featureType: varchar("feature_type", { length: 50 }).default("general"),
|
|
||||||
metadata: jsonb("metadata").default({}),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandVersionIdx: uniqueIndex("changelogs_brand_version_idx").on(
|
|
||||||
t.brandId,
|
|
||||||
t.version,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const changelogReads = pgTable(
|
|
||||||
"changelog_reads",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
userId: uuid("user_id").notNull(),
|
|
||||||
changelogId: uuid("changelog_id").references(() => changelogs.id, {
|
|
||||||
onDelete: "cascade",
|
|
||||||
}),
|
|
||||||
readAt: timestamp("read_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
dismissed: boolean("dismissed").default(false),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
userChangelogIdx: uniqueIndex("changelog_reads_user_changelog_idx").on(
|
|
||||||
t.userId,
|
|
||||||
t.changelogId,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Onboarding ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const onboardingProgress = pgTable(
|
|
||||||
"onboarding_progress",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id").references(() => brands.id, {
|
|
||||||
onDelete: "cascade",
|
|
||||||
}),
|
|
||||||
userId: uuid("user_id").notNull(),
|
|
||||||
currentStep: varchar("current_step", { length: 50 }).notNull(),
|
|
||||||
completedSteps: jsonb("completed_steps").default([]),
|
|
||||||
skippedSteps: jsonb("skipped_steps").default([]),
|
|
||||||
data: jsonb("data").default({}),
|
|
||||||
startedAt: timestamp("started_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
|
||||||
isCompleted: boolean("is_completed").default(false),
|
|
||||||
metadata: jsonb("metadata").default({}),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandUserIdx: uniqueIndex("onboarding_progress_brand_user_idx").on(
|
|
||||||
t.brandId,
|
|
||||||
t.userId,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── API Keys ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const apiKeys = pgTable(
|
|
||||||
"api_keys",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id").references(() => brands.id, {
|
|
||||||
onDelete: "cascade",
|
|
||||||
}),
|
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
|
||||||
keyHash: varchar("key_hash", { length: 255 }).notNull(),
|
|
||||||
keyPrefix: varchar("key_prefix", { length: 20 }),
|
|
||||||
permissions: jsonb("permissions").default(["read"]),
|
|
||||||
rateLimit: integer("rate_limit").default(1000),
|
|
||||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
|
||||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
||||||
isActive: boolean("is_active").default(true),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
createdBy: uuid("created_by").notNull(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("api_keys_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Notification Preferences ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const notificationPreferences = pgTable(
|
|
||||||
"notification_preferences",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
userId: uuid("user_id").notNull(),
|
|
||||||
brandId: uuid("brand_id").references(() => brands.id, {
|
|
||||||
onDelete: "cascade",
|
|
||||||
}),
|
|
||||||
emailOrders: boolean("email_orders").default(true),
|
|
||||||
emailMarketing: boolean("email_marketing").default(false),
|
|
||||||
emailReports: boolean("email_reports").default(true),
|
|
||||||
emailBilling: boolean("email_billing").default(true),
|
|
||||||
smsOrders: boolean("sms_orders").default(false),
|
|
||||||
smsMarketing: boolean("sms_marketing").default(false),
|
|
||||||
pushOrders: boolean("push_orders").default(true),
|
|
||||||
pushMarketing: boolean("push_marketing").default(false),
|
|
||||||
quietHoursStart: time("quiet_hours_start"),
|
|
||||||
quietHoursEnd: time("quiet_hours_end"),
|
|
||||||
timezone: varchar("timezone", { length: 50 }).default("America/New_York"),
|
|
||||||
metadata: jsonb("metadata").default({}),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
userBrandIdx: uniqueIndex("notification_preferences_user_brand_idx").on(
|
|
||||||
t.userId,
|
|
||||||
t.brandId,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Operational Events ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const operationalEvents = pgTable(
|
|
||||||
"operational_events",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
eventType: text("event_type").notNull(),
|
|
||||||
entityType: text("entity_type"),
|
|
||||||
entityId: uuid("entity_id"),
|
|
||||||
actorType: text("actor_type"),
|
|
||||||
actorId: uuid("actor_id"),
|
|
||||||
source: text("source").notNull().default("system"),
|
|
||||||
payload: jsonb("payload").notNull().default({}),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("operational_events_brand_idx").on(t.brandId),
|
|
||||||
typeIdx: index("operational_events_type_idx").on(t.brandId, t.eventType),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Audit Logs ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const auditLogs = pgTable(
|
|
||||||
"audit_logs",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
userId: uuid("user_id"),
|
|
||||||
brandId: uuid("brand_id").references(() => brands.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
action: text("action").notNull(),
|
|
||||||
entityType: text("entity_type"),
|
|
||||||
entityId: uuid("entity_id"),
|
|
||||||
details: jsonb("details"),
|
|
||||||
ipAddress: text("ip_address"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("audit_logs_brand_idx").on(t.brandId),
|
|
||||||
userIdx: index("audit_logs_user_idx").on(t.userId),
|
|
||||||
createdIdx: index("audit_logs_created_idx").on(t.createdAt),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const adminActionLogs = pgTable(
|
|
||||||
"admin_action_logs",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
adminUserId: uuid("admin_user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => adminUsers.id),
|
|
||||||
brandId: uuid("brand_id").references(() => brands.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
action: text("action").notNull(),
|
|
||||||
targetType: text("target_type"),
|
|
||||||
targetId: uuid("target_id"),
|
|
||||||
metadata: jsonb("metadata"),
|
|
||||||
ipAddress: text("ip_address"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
adminIdx: index("admin_action_logs_admin_idx").on(t.adminUserId),
|
|
||||||
createdIdx: index("admin_action_logs_created_idx").on(t.createdAt),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ReferralCode = typeof referralCodes.$inferSelect;
|
|
||||||
export type ReferralRedemption = typeof referralRedemptions.$inferSelect;
|
|
||||||
export type Changelog = typeof changelogs.$inferSelect;
|
|
||||||
export type ChangelogRead = typeof changelogReads.$inferSelect;
|
|
||||||
export type OnboardingProgress = typeof onboardingProgress.$inferSelect;
|
|
||||||
export type ApiKey = typeof apiKeys.$inferSelect;
|
|
||||||
export type NotificationPreference = typeof notificationPreferences.$inferSelect;
|
|
||||||
export type OperationalEvent = typeof operationalEvents.$inferSelect;
|
|
||||||
export type AuditLog = typeof auditLogs.$inferSelect;
|
|
||||||
export type AdminActionLog = typeof adminActionLogs.$inferSelect;
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
/**
|
|
||||||
* Time Tracking. Source: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
numeric,
|
|
||||||
integer,
|
|
||||||
boolean,
|
|
||||||
timestamp,
|
|
||||||
index,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands } from "./brands";
|
|
||||||
|
|
||||||
export const timeTrackingSettings = pgTable(
|
|
||||||
"time_tracking_settings",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.unique()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
payPeriodStartDay: integer("pay_period_start_day").notNull().default(0),
|
|
||||||
payPeriodLengthDays: integer("pay_period_length_days")
|
|
||||||
.notNull()
|
|
||||||
.default(7),
|
|
||||||
dailyOvertimeThreshold: numeric("daily_overtime_threshold", {
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
}).notNull().default("8.0"),
|
|
||||||
weeklyOvertimeThreshold: numeric("weekly_overtime_threshold", {
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
}).notNull().default("40.0"),
|
|
||||||
overtimeMultiplier: numeric("overtime_multiplier", {
|
|
||||||
precision: 3,
|
|
||||||
scale: 2,
|
|
||||||
}).notNull().default("1.50"),
|
|
||||||
overtimeNotifications: boolean("overtime_notifications")
|
|
||||||
.notNull()
|
|
||||||
.default(true),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const timeTrackingWorkers = pgTable(
|
|
||||||
"time_tracking_workers",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
role: text("role", { enum: ["worker", "time_admin"] })
|
|
||||||
.notNull()
|
|
||||||
.default("worker"),
|
|
||||||
lang: text("lang").notNull().default("en"),
|
|
||||||
pin: text("pin").notNull(),
|
|
||||||
active: boolean("active").notNull().default(true),
|
|
||||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("time_tracking_workers_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const timeTrackingTasks = pgTable(
|
|
||||||
"time_tracking_tasks",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
nameEs: text("name_es"),
|
|
||||||
unit: text("unit", { enum: ["hours", "pieces", "units"] })
|
|
||||||
.notNull()
|
|
||||||
.default("hours"),
|
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
|
||||||
active: boolean("active").notNull().default(true),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("time_tracking_tasks_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const timeTrackingLogs = pgTable(
|
|
||||||
"time_tracking_logs",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
workerId: uuid("worker_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => timeTrackingWorkers.id, { onDelete: "cascade" }),
|
|
||||||
taskId: uuid("task_id").references(() => timeTrackingTasks.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
taskName: text("task_name").notNull(),
|
|
||||||
clockIn: timestamp("clock_in", { withTimezone: true }).notNull(),
|
|
||||||
clockOut: timestamp("clock_out", { withTimezone: true }),
|
|
||||||
lunchBreakMinutes: integer("lunch_break_minutes").notNull().default(0),
|
|
||||||
notes: text("notes"),
|
|
||||||
submittedVia: text("submitted_via", {
|
|
||||||
enum: ["manual", "field", "import"],
|
|
||||||
}).notNull().default("manual"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("time_tracking_logs_brand_idx").on(t.brandId),
|
|
||||||
workerIdx: index("time_tracking_logs_worker_idx").on(t.workerId),
|
|
||||||
clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const timeTrackingNotificationLog = pgTable(
|
|
||||||
"time_tracking_notification_log",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
workerId: uuid("worker_id").references(
|
|
||||||
() => timeTrackingWorkers.id,
|
|
||||||
{ onDelete: "set null" },
|
|
||||||
),
|
|
||||||
notificationType: text("notification_type").notNull(),
|
|
||||||
recipient: text("recipient").notNull(),
|
|
||||||
subject: text("subject"),
|
|
||||||
body: text("body").notNull(),
|
|
||||||
status: text("status", {
|
|
||||||
enum: ["pending", "sent", "failed"],
|
|
||||||
}).notNull().default("pending"),
|
|
||||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect;
|
|
||||||
export type TimeTrackingWorker = typeof timeTrackingWorkers.$inferSelect;
|
|
||||||
export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
|
|
||||||
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
|
|
||||||
export type TimeTrackingNotificationLog =
|
|
||||||
typeof timeTrackingNotificationLog.$inferSelect;
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
/**
|
|
||||||
* Water Log. Source: `db/migrations/0001_init.sql` + `0090_water_log_completion.sql`.
|
|
||||||
*
|
|
||||||
* Six tables, all brand-scoped with RLS:
|
|
||||||
* - water_headgates — physical gates a measurement is tied to
|
|
||||||
* - water_irrigators — PIN-authenticated field workers
|
|
||||||
* - water_sessions — short-lived PIN sessions for irrigators
|
|
||||||
* - water_log_entries — the actual reading logs
|
|
||||||
* - water_alert_log — high/low threshold alert history
|
|
||||||
* - water_admin_settings — per-brand admin PIN + alert config
|
|
||||||
* - water_admin_sessions — admin sign-in sessions (separate cookie)
|
|
||||||
* - water_audit_log — who changed what, when
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
numeric,
|
|
||||||
boolean,
|
|
||||||
timestamp,
|
|
||||||
date,
|
|
||||||
jsonb,
|
|
||||||
doublePrecision,
|
|
||||||
index,
|
|
||||||
uniqueIndex,
|
|
||||||
integer,
|
|
||||||
check,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands } from "./brands";
|
|
||||||
import { adminUsers } from "./brands";
|
|
||||||
|
|
||||||
export const waterHeadgates = pgTable(
|
|
||||||
"water_headgates",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
/** Per-headgate opaque token used in the QR code. */
|
|
||||||
headgateToken: text("headgate_token").notNull().unique(),
|
|
||||||
/** Open / Closed / Maintenance. */
|
|
||||||
status: text("status").notNull().default("open"),
|
|
||||||
/** Display unit: CFS, GPM, Inches, AF/Day, etc. */
|
|
||||||
unit: text("unit").notNull().default("CFS"),
|
|
||||||
/** Optional max-flow marker in GPM. */
|
|
||||||
maxFlowGpm: numeric("max_flow_gpm"),
|
|
||||||
/** High-water alert threshold (units match `unit`). */
|
|
||||||
highThreshold: numeric("high_threshold"),
|
|
||||||
/** Low-water alert threshold (units match `unit`). */
|
|
||||||
lowThreshold: numeric("low_threshold"),
|
|
||||||
notes: text("notes"),
|
|
||||||
active: boolean("active").notNull().default(true),
|
|
||||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("water_headgates_brand_idx").on(t.brandId),
|
|
||||||
tokenIdx: uniqueIndex("water_headgates_token_idx").on(t.headgateToken),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const waterIrrigators = pgTable(
|
|
||||||
"water_irrigators",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
pinHash: text("pin_hash").notNull(),
|
|
||||||
languagePreference: text("language_preference")
|
|
||||||
.notNull()
|
|
||||||
.default("en"),
|
|
||||||
/** "irrigator" submits entries only, "water_admin" can manage the brand. */
|
|
||||||
role: text("role").notNull().default("irrigator"),
|
|
||||||
phone: text("phone"),
|
|
||||||
notes: text("notes"),
|
|
||||||
active: boolean("active").notNull().default(true),
|
|
||||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("water_irrigators_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const waterSessions = pgTable(
|
|
||||||
"water_sessions",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
irrigatorId: uuid("irrigator_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
|
|
||||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const waterLogEntries = pgTable(
|
|
||||||
"water_log_entries",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
headgateId: uuid("headgate_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => waterHeadgates.id, { onDelete: "cascade" }),
|
|
||||||
irrigatorId: uuid("irrigator_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
|
|
||||||
/** Raw measurement value, in the entry's `unit`. */
|
|
||||||
measurement: numeric("measurement").notNull(),
|
|
||||||
unit: text("unit").notNull(),
|
|
||||||
/** "manual" | "meter" | "estimate" | "qr" */
|
|
||||||
method: text("method").notNull().default("manual"),
|
|
||||||
/** Optional auto-computed total (in gallons) when CFS × duration is known. */
|
|
||||||
totalGallons: numeric("total_gallons"),
|
|
||||||
notes: text("notes"),
|
|
||||||
submittedVia: text("submitted_via").notNull().default("app"),
|
|
||||||
photoUrl: text("photo_url"),
|
|
||||||
latitude: doublePrecision("latitude"),
|
|
||||||
longitude: doublePrecision("longitude"),
|
|
||||||
/** Date-only mirror of loggedAt for fast grouping / dashboard queries. */
|
|
||||||
loggedDate: date("logged_date"),
|
|
||||||
loggedAt: timestamp("logged_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
loggedBy: uuid("logged_by").references(() => adminUsers.id),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("water_log_entries_brand_idx").on(t.brandId),
|
|
||||||
headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId),
|
|
||||||
brandDateIdx: index("water_log_entries_brand_date_idx").on(
|
|
||||||
t.brandId,
|
|
||||||
t.loggedDate,
|
|
||||||
),
|
|
||||||
irrigatorIdx: index("water_log_entries_irrigator_idx").on(
|
|
||||||
t.irrigatorId,
|
|
||||||
t.loggedAt,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const waterAlertLog = pgTable(
|
|
||||||
"water_alert_log",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
alertType: text("alert_type").notNull(),
|
|
||||||
headgateId: uuid("headgate_id").references(() => waterHeadgates.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
message: text("message").notNull(),
|
|
||||||
sentTo: text("sent_to"),
|
|
||||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const waterAdminSettings = pgTable("water_admin_settings", {
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.primaryKey()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
/** Hashed admin PIN (scrypt $ N$r$p$salt$hash format). */
|
|
||||||
pinHash: text("pin_hash"),
|
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
|
||||||
sessionDurationHours: integer("session_duration_hours")
|
|
||||||
.notNull()
|
|
||||||
.default(4),
|
|
||||||
canEditEntries: boolean("can_edit_entries").notNull().default(true),
|
|
||||||
canDeleteEntries: boolean("can_delete_entries").notNull().default(true),
|
|
||||||
canExportCsv: boolean("can_export_csv").notNull().default(true),
|
|
||||||
alertPhone: text("alert_phone"),
|
|
||||||
alertsEnabled: boolean("alerts_enabled").notNull().default(false),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedBy: uuid("updated_by").references(() => adminUsers.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const waterAdminSessions = pgTable(
|
|
||||||
"water_admin_sessions",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
adminUserId: uuid("admin_user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => adminUsers.id, { onDelete: "cascade" }),
|
|
||||||
pinHashUsed: text("pin_hash_used").notNull(),
|
|
||||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
adminIdx: index("water_admin_sessions_admin_idx").on(
|
|
||||||
t.adminUserId,
|
|
||||||
t.expiresAt,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const waterAuditLog = pgTable(
|
|
||||||
"water_audit_log",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
actorId: uuid("actor_id").references(() => adminUsers.id),
|
|
||||||
actorLabel: text("actor_label").notNull(),
|
|
||||||
action: text("action").notNull(),
|
|
||||||
entityType: text("entity_type").notNull(),
|
|
||||||
entityId: uuid("entity_id"),
|
|
||||||
details: jsonb("details"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandRecentIdx: index("water_audit_log_brand_recent_idx").on(
|
|
||||||
t.brandId,
|
|
||||||
t.createdAt,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Inferred types (used by every action and client component) ────────────
|
|
||||||
|
|
||||||
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
|
|
||||||
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
|
|
||||||
|
|
||||||
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
|
|
||||||
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
|
|
||||||
|
|
||||||
export type WaterSession = typeof waterSessions.$inferSelect;
|
|
||||||
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
|
|
||||||
export type WaterLogEntryInsert = typeof waterLogEntries.$inferInsert;
|
|
||||||
|
|
||||||
export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
|
|
||||||
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
|
|
||||||
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
|
|
||||||
export type WaterAuditLog = typeof waterAuditLog.$inferSelect;
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
/**
|
|
||||||
* Wholesale portal. Source: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
numeric,
|
|
||||||
integer,
|
|
||||||
boolean,
|
|
||||||
timestamp,
|
|
||||||
date,
|
|
||||||
index,
|
|
||||||
uniqueIndex,
|
|
||||||
jsonb,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands } from "./brands";
|
|
||||||
import { authUsers } from "./brands";
|
|
||||||
|
|
||||||
export const wholesaleSettings = pgTable(
|
|
||||||
"wholesale_settings",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.unique()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
requireApproval: boolean("require_approval").notNull().default(true),
|
|
||||||
minOrderAmount: numeric("min_order_amount", { precision: 10, scale: 2 }),
|
|
||||||
onlinePaymentEnabled: boolean("online_payment_enabled")
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
pickupLocation: text("pickup_location"),
|
|
||||||
fobLocation: text("fob_location"),
|
|
||||||
fromEmail: text("from_email"),
|
|
||||||
invoiceBusinessName: text("invoice_business_name"),
|
|
||||||
lastInvoiceNumber: integer("last_invoice_number").notNull().default(0),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const wholesaleCustomers = pgTable(
|
|
||||||
"wholesale_customers",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
userId: uuid("user_id").references(() => authUsers.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
companyName: text("company_name"),
|
|
||||||
contactName: text("contact_name"),
|
|
||||||
email: text("email"),
|
|
||||||
phone: text("phone"),
|
|
||||||
billingAddress: text("billing_address"),
|
|
||||||
shippingAddress: text("shipping_address"),
|
|
||||||
accountStatus: text("account_status", {
|
|
||||||
enum: ["active", "suspended", "inactive"],
|
|
||||||
}).notNull().default("active"),
|
|
||||||
creditLimit: numeric("credit_limit", { precision: 10, scale: 2 })
|
|
||||||
.notNull()
|
|
||||||
.default("0"),
|
|
||||||
depositsEnabled: boolean("deposits_enabled").notNull().default(false),
|
|
||||||
depositThreshold: numeric("deposit_threshold", { precision: 10, scale: 2 }),
|
|
||||||
depositPercentage: integer("deposit_percentage"),
|
|
||||||
orderEmail: text("order_email"),
|
|
||||||
invoiceEmail: text("invoice_email"),
|
|
||||||
adminNotes: text("admin_notes"),
|
|
||||||
role: text("role", { enum: ["buyer", "admin"] })
|
|
||||||
.notNull()
|
|
||||||
.default("buyer"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("wholesale_customers_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const wholesaleProducts = pgTable(
|
|
||||||
"wholesale_products",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
rcProductId: uuid("rc_product_id"),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
description: text("description"),
|
|
||||||
unitType: text("unit_type").notNull().default("each"),
|
|
||||||
availability: text("availability", {
|
|
||||||
enum: ["available", "unavailable", "limited", "seasonal"],
|
|
||||||
}).notNull().default("unavailable"),
|
|
||||||
qtyAvailable: numeric("qty_available", { precision: 10, scale: 2 })
|
|
||||||
.default("0"),
|
|
||||||
priceTiers: jsonb("price_tiers").notNull().default([]),
|
|
||||||
hpSku: text("hp_sku"),
|
|
||||||
hpItemId: text("hp_item_id"),
|
|
||||||
internalNotes: text("internal_notes"),
|
|
||||||
handlingInstructions: text("handling_instructions"),
|
|
||||||
transportTemp: text("transport_temp"),
|
|
||||||
storageWarning: text("storage_warning"),
|
|
||||||
loadingNotes: text("loading_notes"),
|
|
||||||
productLabel: text("product_label"),
|
|
||||||
packStyle: text("pack_style"),
|
|
||||||
containerType: text("container_type"),
|
|
||||||
containerSizeCode: text("container_size_code"),
|
|
||||||
unitsPerContainer: integer("units_per_container"),
|
|
||||||
containerNotes: text("container_notes"),
|
|
||||||
defaultPickupLocation: text("default_pickup_location"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("wholesale_products_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const wholesaleOrders = pgTable(
|
|
||||||
"wholesale_orders",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
customerId: uuid("customer_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => wholesaleCustomers.id),
|
|
||||||
wcOrderId: numeric("wc_order_id"),
|
|
||||||
status: text("status", {
|
|
||||||
enum: [
|
|
||||||
"pending", "confirmed", "in_production", "ready",
|
|
||||||
"fulfilled", "canceled",
|
|
||||||
],
|
|
||||||
}).notNull().default("pending"),
|
|
||||||
fulfillmentStatus: text("fulfillment_status", {
|
|
||||||
enum: ["unfulfilled", "partial", "fulfilled"],
|
|
||||||
}).notNull().default("unfulfilled"),
|
|
||||||
paymentStatus: text("payment_status", {
|
|
||||||
enum: ["unpaid", "deposit_paid", "paid", "refunded"],
|
|
||||||
}).notNull().default("unpaid"),
|
|
||||||
anticipatedPickupDate: date("anticipated_pickup_date"),
|
|
||||||
subtotal: numeric("subtotal", { precision: 10, scale: 2 })
|
|
||||||
.notNull()
|
|
||||||
.default("0"),
|
|
||||||
depositRequired: numeric("deposit_required", { precision: 10, scale: 2 })
|
|
||||||
.default("0"),
|
|
||||||
depositPaid: numeric("deposit_paid", { precision: 10, scale: 2 })
|
|
||||||
.notNull()
|
|
||||||
.default("0"),
|
|
||||||
balanceDue: numeric("balance_due", { precision: 10, scale: 2 })
|
|
||||||
.notNull()
|
|
||||||
.default("0"),
|
|
||||||
assignedEmployeeId: uuid("assigned_employee_id"),
|
|
||||||
fulfillmentNotes: text("fulfillment_notes"),
|
|
||||||
internalNotes: text("internal_notes"),
|
|
||||||
invoiceNumber: text("invoice_number"),
|
|
||||||
invoicePdfPath: text("invoice_pdf_path"),
|
|
||||||
invoiceToken: text("invoice_token"),
|
|
||||||
invoiceType: text("invoice_type"),
|
|
||||||
depositPercentage: integer("deposit_percentage"),
|
|
||||||
fulfilledAt: timestamp("fulfilled_at", { withTimezone: true }),
|
|
||||||
fulfilledBy: uuid("fulfilled_by"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("wholesale_orders_brand_idx").on(t.brandId),
|
|
||||||
customerIdx: index("wholesale_orders_customer_idx").on(t.customerId),
|
|
||||||
statusIdx: index("wholesale_orders_status_idx").on(t.brandId, t.status),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const wholesaleOrderItems = pgTable(
|
|
||||||
"wholesale_order_items",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
wholesaleOrderId: uuid("wholesale_order_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => wholesaleOrders.id, { onDelete: "cascade" }),
|
|
||||||
productId: uuid("product_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => wholesaleProducts.id),
|
|
||||||
quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(),
|
|
||||||
unitPrice: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
|
|
||||||
lineTotal: numeric("line_total", { precision: 10, scale: 2 }).notNull(),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
orderIdx: index("wholesale_order_items_order_idx").on(
|
|
||||||
t.wholesaleOrderId,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const wholesaleDeposits = pgTable(
|
|
||||||
"wholesale_deposits",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
wholesaleOrderId: uuid("wholesale_order_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => wholesaleOrders.id, { onDelete: "cascade" }),
|
|
||||||
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
|
|
||||||
paymentMethod: text("payment_method"),
|
|
||||||
reference: text("reference"),
|
|
||||||
recordedBy: uuid("recorded_by").notNull(),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const wholesaleNotifications = pgTable(
|
|
||||||
"wholesale_notifications",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
customerId: uuid("customer_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => wholesaleCustomers.id, { onDelete: "cascade" }),
|
|
||||||
notificationType: text("notification_type", {
|
|
||||||
enum: [
|
|
||||||
"order_confirmed", "order_ready", "pickup_reminder",
|
|
||||||
"payment_reminder", "invoice",
|
|
||||||
],
|
|
||||||
}).notNull(),
|
|
||||||
channel: text("channel", { enum: ["email", "sms"] }).notNull(),
|
|
||||||
recipient: text("recipient").notNull(),
|
|
||||||
subject: text("subject"),
|
|
||||||
body: text("body").notNull(),
|
|
||||||
status: text("status", {
|
|
||||||
enum: ["pending", "sent", "failed"],
|
|
||||||
}).notNull().default("pending"),
|
|
||||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
|
||||||
errorMessage: text("error_message"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("wholesale_notifications_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const wholesaleCustomerProductPricing = pgTable(
|
|
||||||
"wholesale_customer_product_pricing",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
customerId: uuid("customer_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => wholesaleCustomers.id, { onDelete: "cascade" }),
|
|
||||||
productId: uuid("product_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => wholesaleProducts.id, { onDelete: "cascade" }),
|
|
||||||
customPriceCents: integer("custom_price_cents").notNull(),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
customerProductIdx: uniqueIndex(
|
|
||||||
"wholesale_customer_product_pricing_cust_prod_idx",
|
|
||||||
).on(t.customerId, t.productId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const wholesaleWebhookSettings = pgTable(
|
|
||||||
"wholesale_webhook_settings",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.unique()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
url: text("url").notNull(),
|
|
||||||
secret: text("secret").notNull(),
|
|
||||||
enabled: boolean("enabled").notNull().default(false),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const wholesaleSyncLog = pgTable(
|
|
||||||
"wholesale_sync_log",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
eventType: text("event_type").notNull(),
|
|
||||||
orderId: uuid("order_id"),
|
|
||||||
payload: jsonb("payload"),
|
|
||||||
status: text("status", {
|
|
||||||
enum: ["pending", "sent", "failed"],
|
|
||||||
}).notNull().default("pending"),
|
|
||||||
response: text("response"),
|
|
||||||
attempts: integer("attempts").notNull().default(0),
|
|
||||||
nextRetry: timestamp("next_retry", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("wholesale_sync_log_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const userCarts = pgTable(
|
|
||||||
"user_carts",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
customerId: uuid("customer_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => wholesaleCustomers.id, { onDelete: "cascade" }),
|
|
||||||
items: jsonb("items").notNull().default([]),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
customerIdx: uniqueIndex("user_carts_customer_idx").on(
|
|
||||||
t.brandId,
|
|
||||||
t.customerId,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type WholesaleSettings = typeof wholesaleSettings.$inferSelect;
|
|
||||||
export type WholesaleCustomer = typeof wholesaleCustomers.$inferSelect;
|
|
||||||
export type WholesaleProduct = typeof wholesaleProducts.$inferSelect;
|
|
||||||
export type WholesaleOrder = typeof wholesaleOrders.$inferSelect;
|
|
||||||
export type WholesaleOrderItem = typeof wholesaleOrderItems.$inferSelect;
|
|
||||||
export type WholesaleDeposit = typeof wholesaleDeposits.$inferSelect;
|
|
||||||
export type WholesaleNotification = typeof wholesaleNotifications.$inferSelect;
|
|
||||||
export type WholesaleWebhookSettings =
|
|
||||||
typeof wholesaleWebhookSettings.$inferSelect;
|
|
||||||
export type WholesaleSyncLog = typeof wholesaleSyncLog.$inferSelect;
|
|
||||||
export type UserCart = typeof userCarts.$inferSelect;
|
|
||||||
-177
@@ -1,177 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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:
|
|
||||||
* - 2 brands (Tuxedo, Indian River Direct)
|
|
||||||
* - brand_settings per brand
|
|
||||||
* - sample products, stops, customers per brand
|
|
||||||
* - sample communication templates + a draft campaign
|
|
||||||
*
|
|
||||||
* NOTE: Admin users are managed by Neon Auth. To create an admin user:
|
|
||||||
* 1. Sign up / sign in via the app (creates a neon_auth.user row)
|
|
||||||
* 2. Manually insert into admin_users + admin_user_brands:
|
|
||||||
* INSERT INTO admin_users (email, name, role) VALUES ('you@example.com', 'Your Name', 'brand_admin');
|
|
||||||
* INSERT INTO admin_user_brands (admin_user_id, brand_id, role) VALUES (<admin_user_id>, <brand_id>, 'brand_admin');
|
|
||||||
*/
|
|
||||||
import "dotenv/config";
|
|
||||||
import { Pool } from "pg";
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
// ── Brands ──────────────────────────────────────────────────────────────
|
|
||||||
const brandsData = [
|
|
||||||
{
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const b of brandsData) {
|
|
||||||
// Upsert brand
|
|
||||||
const brandRes = await client.query<{ id: string }>(
|
|
||||||
`INSERT INTO brands (name, slug, plan_tier)
|
|
||||||
VALUES ($1, $2, 'starter')
|
|
||||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
|
||||||
RETURNING id`,
|
|
||||||
[b.name, b.slug],
|
|
||||||
);
|
|
||||||
const brandId = brandRes.rows[0].id;
|
|
||||||
|
|
||||||
// Brand settings (PK is brand_id)
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO brand_settings
|
|
||||||
(brand_id, brand_name, tagline, about_html, primary_color, contact_email, contact_phone)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
ON CONFLICT (brand_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`,
|
|
||||||
[brandId, b.brandName, b.tagline, b.aboutHtml, b.primaryColor, b.contactEmail, b.contactPhone],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sample products
|
|
||||||
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 (brand_id, name, description, price_cents, inventory, unit, active)
|
|
||||||
SELECT $1, $2, $3, $4, 100, $5, true
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM products WHERE brand_id = $1 AND name = $2
|
|
||||||
)`,
|
|
||||||
[brandId, 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 (brand_id, name, address, schedule, status)
|
|
||||||
SELECT $1, $2, $3, $4::jsonb, 'active'
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM stops WHERE brand_id = $1 AND name = $2
|
|
||||||
)`,
|
|
||||||
[brandId, s.name, s.address, JSON.stringify(s.schedule)],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sample customers
|
|
||||||
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 (brand_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 brand_id = $1 AND email = $3
|
|
||||||
)`,
|
|
||||||
[brandId, c.name, c.email, c.phone],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sample communication template
|
|
||||||
const tmplRes = await client.query<{ id: string }>(
|
|
||||||
`INSERT INTO communication_templates (brand_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 communication_templates WHERE brand_id = $1 AND name = 'Weekly Availability'
|
|
||||||
)
|
|
||||||
RETURNING id`,
|
|
||||||
[brandId],
|
|
||||||
);
|
|
||||||
if (tmplRes.rows[0]) {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO communication_campaigns (brand_id, template_id, name, status)
|
|
||||||
SELECT $1, $2, 'Welcome series — week 1', 'draft'
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM communication_campaigns WHERE brand_id = $1 AND name = 'Welcome series — week 1'
|
|
||||||
)`,
|
|
||||||
[brandId, tmplRes.rows[0].id],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`Seeded ${brandsData.length} brands with sample data`);
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,53 @@
|
|||||||
|
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);
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# .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>"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# Runtime artefacts written by deploy.sh — do NOT commit these.
|
|
||||||
.deploy.lock
|
|
||||||
deploy.log
|
|
||||||
.postgrest-port
|
|
||||||
.nextjs-port
|
|
||||||
.env.production
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# Dockerfile.nextjs — multi-stage build for the Next.js frontend
|
|
||||||
# =============================================================================
|
|
||||||
#
|
|
||||||
# Production-ready Docker image for Next.js with:
|
|
||||||
# - Multi-stage build for minimal image size
|
|
||||||
# - Standalone server output for containerized deployment
|
|
||||||
# - Non-root user for security
|
|
||||||
# - Health check support
|
|
||||||
#
|
|
||||||
# Build args (set via --build-arg or docker-compose args):
|
|
||||||
# - NEXT_PUBLIC_API_URL: Public URL for the frontend (inlined at build time)
|
|
||||||
# - NODE_ENV: Environment (defaults to production)
|
|
||||||
#
|
|
||||||
# Required: next.config.ts must have `output: "standalone"` enabled.
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# ── Stage 1: Dependencies ────────────────────────────────────────────────────
|
|
||||||
FROM node:20-alpine AS deps
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package.json package-lock.json* ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN if [ -f package-lock.json ]; then \
|
|
||||||
npm ci --no-audit --no-fund; \
|
|
||||||
else \
|
|
||||||
npm install --no-audit --no-fund; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Stage 2: Build ───────────────────────────────────────────────────────────
|
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy node_modules from deps stage
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
|
|
||||||
# Copy source files
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Set build arguments (inlined into client bundle)
|
|
||||||
ARG NEXT_PUBLIC_API_URL=http://localhost:3000
|
|
||||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
|
||||||
ARG NODE_ENV=production
|
|
||||||
ENV NODE_ENV=${NODE_ENV}
|
|
||||||
|
|
||||||
# Build the Next.js application
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ── Stage 3: Runtime ─────────────────────────────────────────────────────────
|
|
||||||
FROM node:20-alpine AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Set production environment
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=3000
|
|
||||||
|
|
||||||
# Create non-root user for security
|
|
||||||
RUN addgroup --system --gid 1001 nodejs \
|
|
||||||
&& adduser --system --uid 1001 nextjs
|
|
||||||
|
|
||||||
# Copy standalone server and static assets from builder
|
|
||||||
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
|
|
||||||
|
|
||||||
# Expose the port
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=6 \
|
|
||||||
CMD wget -qO- http://localhost:3000/ || exit 1
|
|
||||||
|
|
||||||
# Start the standalone server
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# 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
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# docker-compose.yml — production stack consumed by deploy.sh
|
|
||||||
# =============================================================================
|
|
||||||
#
|
|
||||||
# Complete Docker stack for production deployment:
|
|
||||||
# - Next.js frontend (Node.js standalone server)
|
|
||||||
# - PostgREST API (optional, for direct PostgreSQL access)
|
|
||||||
#
|
|
||||||
# Postgres itself runs on the host (the deploy workflow applies migrations
|
|
||||||
# via `psql -h 127.0.0.1`). Next.js can also run under PM2 on the host —
|
|
||||||
# this compose file provides a Docker alternative for the frontend.
|
|
||||||
#
|
|
||||||
# The host-side ports are written by the deploy workflow into $APP_DIR/.env.
|
|
||||||
# We interpolate from there with ${VAR:-default} so a manual
|
|
||||||
# `docker compose up` without the deploy script still works.
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
name: prod-app # default project name; deploy.sh overrides with -p
|
|
||||||
|
|
||||||
services:
|
|
||||||
# ── Next.js Frontend ──────────────────────────────────────────────────────────
|
|
||||||
nextjs:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: deploy/Dockerfile.nextjs
|
|
||||||
args:
|
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3000}
|
|
||||||
NODE_ENV: production
|
|
||||||
container_name: prod-app-nextjs
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "${NEXTJS_HOST_PORT:-3000}:3000"
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3000}
|
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
|
||||||
# Pass other env vars as needed
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 6
|
|
||||||
start_period: 30s
|
|
||||||
# Volume mount for hot-reload in development (comment out for production)
|
|
||||||
# volumes:
|
|
||||||
# - ../src:/app/src:ro
|
|
||||||
|
|
||||||
# ── PostgREST (optional direct PostgreSQL access) ────────────────────────────
|
|
||||||
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"
|
|
||||||
depends_on:
|
|
||||||
nextjs:
|
|
||||||
condition: service_healthy
|
|
||||||
# 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
|
|
||||||
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# 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,83 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: route_commerce_db
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
postgrest:
|
||||||
|
image: postgrest/postgrest:v12.2.3
|
||||||
|
container_name: route_commerce_postgrest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PGRST_DB_URI: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
PGRST_DB_SCHEMA: public
|
||||||
|
PGRST_DB_ANON_ROLE: ${POSTGRES_USER}
|
||||||
|
PGRST_SERVER_PORT: 3001
|
||||||
|
PGRST_SERVER_HOST: 0.0.0.0
|
||||||
|
PGRST_OPENAPI_MODE: disabled
|
||||||
|
PGRST_DB_EXTRA_SEARCH_PATH: public,extensions
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3001:3001"
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: route_commerce_minio
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:9000:9000"
|
||||||
|
- "127.0.0.1:9001:9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
minio_init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}
|
||||||
|
for b in brand-logos product-images contacts-imports videos water-photos; do
|
||||||
|
mc mb --ignore-existing local/$${b}
|
||||||
|
mc anonymous set download local/$${b}
|
||||||
|
done
|
||||||
|
profiles: ["init"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
driver: local
|
||||||
|
minio_data:
|
||||||
|
driver: local
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
# Nested Layout Skeleton
|
|
||||||
|
|
||||||
Visual map of how pages nest inside each other in the Next.js App Router.
|
|
||||||
Each level persists across navigation — children swap, parents stay.
|
|
||||||
|
|
||||||
## Mental model
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ app/layout.tsx (root) │
|
|
||||||
│ html / body / fonts / Providers / Toast / CookieBanner │
|
|
||||||
│ ───────────────────────────────────────────────────────── │
|
|
||||||
│ No global header. Each route group renders its own chrome. │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌─────────────────────────┼─────────────────────────────┐
|
|
||||||
│ │ │
|
|
||||||
▼ ▼ ▼
|
|
||||||
Public routes Storefronts Admin
|
|
||||||
(/, /pricing, (/tuxedo/*, (/admin/*)
|
|
||||||
/contact, /login) /indian-river-direct/*)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Full tree
|
|
||||||
|
|
||||||
```
|
|
||||||
src/app/
|
|
||||||
├── layout.tsx ────────────────────────────────────────────────── [ROOT]
|
|
||||||
│ • <html>, <body>
|
|
||||||
│ • next/font: Fraunces (display), Manrope (sans), Fragment_Mono
|
|
||||||
│ • <Providers> (theme, posthog, sentry, etc.)
|
|
||||||
│ • <ToastNotificationContainer />
|
|
||||||
│ • <CookieConsentBanner />
|
|
||||||
│ ─── pages render INSIDE ───
|
|
||||||
│ │
|
|
||||||
│ ├── error.tsx [ROOT-LEVEL] Atelier error page
|
|
||||||
│ ├── loading.tsx [ROOT-LEVEL] "Preparing your harvest"
|
|
||||||
│ ├── not-found.tsx [ROOT-LEVEL] "Off the beaten path"
|
|
||||||
│ │
|
|
||||||
│ ├── page.tsx / LandingPageClient
|
|
||||||
│ │ └── renders its own SiteHeader + Hero + SiteFooter
|
|
||||||
│ │
|
|
||||||
│ ├── login/
|
|
||||||
│ │ ├── page.tsx /login LoginClient (standalone)
|
|
||||||
│ │ └── loading.tsx [isolated]
|
|
||||||
│ │
|
|
||||||
│ ├── cart/
|
|
||||||
│ │ ├── page.tsx /cart CartClient (standalone)
|
|
||||||
│ │ └── loading.tsx
|
|
||||||
│ │
|
|
||||||
│ ├── checkout/
|
|
||||||
│ │ ├── page.tsx /checkout CheckoutClient (standalone)
|
|
||||||
│ │ ├── loading.tsx
|
|
||||||
│ │ └── success/page.tsx /checkout/success
|
|
||||||
│ │
|
|
||||||
│ ├── change-password/page.tsx
|
|
||||||
│ ├── pricing/page.tsx + loading.tsx
|
|
||||||
│ ├── contact/page.tsx + loading.tsx
|
|
||||||
│ ├── blog/page.tsx
|
|
||||||
│ ├── changelog/page.tsx
|
|
||||||
│ ├── roadmap/page.tsx
|
|
||||||
│ ├── waitlist/page.tsx
|
|
||||||
│ ├── security/page.tsx
|
|
||||||
│ ├── privacy-policy/page.tsx
|
|
||||||
│ ├── terms-and-conditions/page.tsx
|
|
||||||
│ ├── maintenance/page.tsx
|
|
||||||
│ │
|
|
||||||
│ ├── ─── STOREFRONTS ─────────────────────────────────────────
|
|
||||||
│ │
|
|
||||||
│ ├── tuxedo/
|
|
||||||
│ │ ├── layout.tsx ─────────────────────── [TUXEDO CHROME]
|
|
||||||
│ │ │ • Dark emerald gradient backdrop
|
|
||||||
│ │ │ • Ambient emerald orb (top-right blur)
|
|
||||||
│ │ │ • <StorefrontHeader> + <StorefrontFooter> per page
|
|
||||||
│ │ │ • BreadcrumbList + LocalBusiness JSON-LD in metadata
|
|
||||||
│ │ │ ─── INSIDE ───
|
|
||||||
│ │ │ ├── error.tsx
|
|
||||||
│ │ │ ├── loading.tsx
|
|
||||||
│ │ │ ├── page.tsx /tuxedo
|
|
||||||
│ │ │ ├── stops/
|
|
||||||
│ │ │ │ ├── page.tsx /tuxedo/stops
|
|
||||||
│ │ │ │ ├── loading.tsx
|
|
||||||
│ │ │ │ └── [slug]/
|
|
||||||
│ │ │ │ ├── page.tsx /tuxedo/stops/:slug
|
|
||||||
│ │ │ │ ├── error.tsx
|
|
||||||
│ │ │ │ └── loading.tsx
|
|
||||||
│ │ │ ├── about/
|
|
||||||
│ │ │ │ ├── layout.tsx
|
|
||||||
│ │ │ │ ├── page.tsx /tuxedo/about
|
|
||||||
│ │ │ │ ├── error.tsx
|
|
||||||
│ │ │ │ └── loading.tsx
|
|
||||||
│ │ │ ├── faq/
|
|
||||||
│ │ │ │ ├── layout.tsx
|
|
||||||
│ │ │ │ ├── page.tsx /tuxedo/faq
|
|
||||||
│ │ │ │ ├── error.tsx
|
|
||||||
│ │ │ │ └── loading.tsx
|
|
||||||
│ │ │ ├── contact/
|
|
||||||
│ │ │ │ ├── layout.tsx
|
|
||||||
│ │ │ │ ├── page.tsx /tuxedo/contact
|
|
||||||
│ │ │ │ ├── error.tsx
|
|
||||||
│ │ │ │ └── loading.tsx
|
|
||||||
│ │ │ ├── products/
|
|
||||||
│ │ │ │ └── [slug]/page.tsx /tuxedo/products/:slug
|
|
||||||
│ │ │ └── time-clock/page.tsx /tuxedo/time-clock
|
|
||||||
│ │
|
|
||||||
│ ├── indian-river-direct/
|
|
||||||
│ │ ├── layout.tsx ─────────────────── [IRD CHROME]
|
|
||||||
│ │ │ • Light blue/white glass gradient backdrop
|
|
||||||
│ │ │ • Three decorative blue orbs
|
|
||||||
│ │ │ • <StorefrontHeader> + <StorefrontFooter> per page
|
|
||||||
│ │ │ • LocalBusiness (FL, family-owned 1985) JSON-LD
|
|
||||||
│ │ │ ─── INSIDE ───
|
|
||||||
│ │ │ ├── error.tsx
|
|
||||||
│ │ │ ├── loading.tsx
|
|
||||||
│ │ │ ├── page.tsx /indian-river-direct
|
|
||||||
│ │ │ ├── stops/
|
|
||||||
│ │ │ │ ├── page.tsx
|
|
||||||
│ │ │ │ └── [slug]/
|
|
||||||
│ │ │ │ ├── page.tsx
|
|
||||||
│ │ │ │ ├── error.tsx
|
|
||||||
│ │ │ │ └── loading.tsx
|
|
||||||
│ │ │ ├── about/ (layout + page + error + loading)
|
|
||||||
│ │ │ ├── faq/ (layout + page + error + loading)
|
|
||||||
│ │ │ └── contact/ (layout + page + error + loading)
|
|
||||||
│ │
|
|
||||||
│ ├── ─── ADMIN ──────────────────────────────────────────────
|
|
||||||
│ │
|
|
||||||
│ └── admin/
|
|
||||||
│ ├── layout.tsx ───────────────────── [ADMIN SHELL]
|
|
||||||
│ │ • dynamic = "force-dynamic" (reads cookies)
|
|
||||||
│ │ • getAdminUser() → AdminSidebar + content area
|
|
||||||
│ │ • ToastProvider + ToastContainer
|
|
||||||
│ │ • Sidebar persists across all /admin/* routes
|
|
||||||
│ │ • Parchment background via .admin-section
|
|
||||||
│ │ ─── INSIDE ───
|
|
||||||
│ │ ├── error.tsx Admin error page
|
|
||||||
│ │ ├── loading.tsx Skeleton: header + stats + table
|
|
||||||
│ │ ├── page.tsx /admin (Dashboard)
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── orders/
|
|
||||||
│ │ │ ├── page.tsx /admin/orders
|
|
||||||
│ │ │ ├── [id]/page.tsx /admin/orders/:id
|
|
||||||
│ │ │ └── new/page.tsx /admin/orders/new
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── products/
|
|
||||||
│ │ │ ├── page.tsx /admin/products
|
|
||||||
│ │ │ ├── [id]/page.tsx
|
|
||||||
│ │ │ ├── new/page.tsx
|
|
||||||
│ │ │ └── import/page.tsx
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── stops/
|
|
||||||
│ │ │ ├── page.tsx /admin/stops
|
|
||||||
│ │ │ ├── [id]/page.tsx
|
|
||||||
│ │ │ └── new/page.tsx
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── communications/
|
|
||||||
│ │ │ ├── loading.tsx
|
|
||||||
│ │ │ ├── page.tsx /admin/communications
|
|
||||||
│ │ │ ├── compose/page.tsx
|
|
||||||
│ │ │ ├── campaigns/[id]/page.tsx
|
|
||||||
│ │ │ ├── contacts/page.tsx
|
|
||||||
│ │ │ ├── logs/page.tsx
|
|
||||||
│ │ │ ├── segments/page.tsx
|
|
||||||
│ │ │ ├── settings/page.tsx
|
|
||||||
│ │ │ ├── templates/page.tsx
|
|
||||||
│ │ │ ├── templates/[id]/page.tsx
|
|
||||||
│ │ │ ├── analytics/page.tsx
|
|
||||||
│ │ │ ├── abandoned-carts/page.tsx
|
|
||||||
│ │ │ └── welcome-sequence/page.tsx
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── settings/ (sub-tree of 13 pages: billing, brand,
|
|
||||||
│ │ │ payments, shipping, ai, apps,
|
|
||||||
│ │ │ integrations, square-sync, ...)
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── route-trace/ (lots, lookup, settings)
|
|
||||||
│ │ ├── me/
|
|
||||||
│ │ ├── pickup/ Store-employee landing
|
|
||||||
│ │ ├── sales/import/
|
|
||||||
│ │ ├── import/
|
|
||||||
│ │ ├── analytics/
|
|
||||||
│ │ ├── command-center/
|
|
||||||
│ │ ├── advanced/
|
|
||||||
│ │ ├── reports/
|
|
||||||
│ │ ├── shipping/
|
|
||||||
│ │ ├── taxes/
|
|
||||||
│ │ ├── time-tracking/
|
|
||||||
│ │ ├── water-log/ (sub-tree)
|
|
||||||
│ │ ├── wholesale/
|
|
||||||
│ │ └── launch-checklist/
|
|
||||||
│ │
|
|
||||||
│ ├── wholesale/ (separate sub-app, not under admin/layout)
|
|
||||||
│ │ ├── login/page.tsx
|
|
||||||
│ │ ├── portal/page.tsx
|
|
||||||
│ │ ├── register/page.tsx
|
|
||||||
│ │ ├── employee/page.tsx
|
|
||||||
│ │ └── payment/{success,cancel}/page.tsx
|
|
||||||
│ │
|
|
||||||
│ ├── water/ (separate sub-app, not under admin/layout)
|
|
||||||
│ │ ├── page.tsx
|
|
||||||
│ │ ├── loading.tsx
|
|
||||||
│ │ └── admin/ (login + page)
|
|
||||||
│ │
|
|
||||||
│ ├── ird/time-clock/ (time-clock for IRD employees)
|
|
||||||
│ ├── trace/[lotNumber]/page.tsx (public route-trace lookup)
|
|
||||||
│ └── protected-example/page.tsx (smoke test for middleware)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key nesting rules
|
|
||||||
|
|
||||||
1. **The root `app/layout.tsx` is the only one that renders `<html>` and `<body>`**. Every other layout is a child of it.
|
|
||||||
2. **A `layout.tsx` in a folder wraps every `page.tsx` in that folder and all subfolders**. Children swap; layout state persists.
|
|
||||||
3. **`loading.tsx` and `error.tsx` are per-folder** — Next.js shows them when navigating to a sibling `page.tsx`. They re-render as you move between sections.
|
|
||||||
4. **Storefronts are fully isolated**: each `tuxedo/layout.tsx` and `indian-river-direct/layout.tsx` provides its own backdrop and lets individual pages render their own `StorefrontHeader` / `StorefrontFooter`.
|
|
||||||
5. **Admin is a single SPA-feeling shell**: navigating between `/admin/orders` and `/admin/products` keeps the `AdminSidebar` mounted, only the content area swaps.
|
|
||||||
|
|
||||||
## Things that DO NOT yet nest (gaps)
|
|
||||||
|
|
||||||
| Page | Issue | Fix |
|
|
||||||
|---|---|---|
|
|
||||||
| `/cart` | No shared layout with `/checkout` | Add `app/(shop)/layout.tsx` route group to wrap both |
|
|
||||||
| `/wholesale/*` | Wholesale pages have no shared layout | Add `app/wholesale/layout.tsx` with auth gate + nav |
|
|
||||||
| `/water/*` | Water pages have no shared layout | Add `app/water/layout.tsx` |
|
|
||||||
| `/admin/communications/*` | 13 pages share no sub-layout | Could add `app/admin/communications/layout.tsx` with section tabs |
|
|
||||||
| `/admin/settings/*` | 13 settings pages share no sub-layout | Add `app/admin/settings/layout.tsx` with settings sidebar |
|
|
||||||
|
|
||||||
## How to add a nested layout
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Example: add a settings sub-layout
|
|
||||||
mkdir -p src/app/admin/settings
|
|
||||||
# Create src/app/admin/settings/layout.tsx
|
|
||||||
# It will wrap every page under /admin/settings/* automatically.
|
|
||||||
```
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// src/app/admin/settings/layout.tsx
|
|
||||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[14rem_1fr] gap-6">
|
|
||||||
<SettingsNav /> {/* sticks while content swaps */}
|
|
||||||
<section>{children}</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## How to add parallel routes (modals as overlays)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p 'src/app/@modal'
|
|
||||||
mkdir -p 'src/app/admin/products/(.)new' # intercepts /admin/products/new
|
|
||||||
# Layout returns <>{children}{modal}</>
|
|
||||||
```
|
|
||||||
|
|
||||||
This makes `/admin/products/new` open as a modal over `/admin/products` instead of a hard navigation.
|
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
# Supabase Dump & Restore Guide
|
||||||
|
|
||||||
|
This guide is the runbook for capturing the real Supabase schema and data
|
||||||
|
and restoring it to a local Postgres database. It documents the exact
|
||||||
|
steps that worked when the spend cap was removed on 2026-06-05.
|
||||||
|
|
||||||
|
## Connection (this works from the dev box)
|
||||||
|
|
||||||
|
The Supabase project `wnzkhezyhnfzhkhiflrp` (route-commerce) is hosted in
|
||||||
|
**East US (North Virginia)**. The dev box has no IPv6, so we must use
|
||||||
|
the **Supavisor pooler** (IPv4 only) — and it's on **`aws-1`**, not `aws-0`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Working pooler URL (from supabase/.temp/pooler-url)
|
||||||
|
postgresql://postgres.wnzkhezyhnfzhkhiflrp:YLKzP9jz2yqop7jr@aws-1-us-east-1.pooler.supabase.com:5432/postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
The `aws-0-us-east-1.pooler.supabase.com` hostname resolves over IPv4
|
||||||
|
but Supavisor returns "tenant/user not found" because the project lives
|
||||||
|
on `aws-1`. The correct region number is non-obvious — always check
|
||||||
|
`supabase/.temp/pooler-url` for the actual endpoint.
|
||||||
|
|
||||||
|
The direct hostname `db.wnzkhezyhnfzhkhiflrp.supabase.co` only resolves
|
||||||
|
over IPv6, which is unreachable from this dev box.
|
||||||
|
|
||||||
|
## pg_dump version
|
||||||
|
|
||||||
|
Supabase runs **PostgreSQL 17.6**. Local pg must be ≥ 17 to dump cleanly.
|
||||||
|
The dev box had pg 16 by default — install pg 17 client:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt/ noble-pgdg main" \
|
||||||
|
| sudo tee /etc/apt/sources.list.d/pgdg.list
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --allow-unauthenticated postgresql-client-17
|
||||||
|
# Use /usr/lib/postgresql/17/bin/pg_dump explicitly (or update PATH)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Capture Schema
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PGPASSWORD="YLKzP9jz2yqop7jr"
|
||||||
|
export PATH="/usr/lib/postgresql/17/bin:$PATH"
|
||||||
|
|
||||||
|
mkdir -p supabase/captured
|
||||||
|
pg_dump \
|
||||||
|
--host=aws-1-us-east-1.pooler.supabase.com \
|
||||||
|
--port=5432 \
|
||||||
|
--username=postgres.wnzkhezyhnfzhkhiflrp \
|
||||||
|
--dbname=postgres \
|
||||||
|
--schema-only \
|
||||||
|
--no-owner \
|
||||||
|
--no-privileges \
|
||||||
|
--no-acl \
|
||||||
|
--exclude-schema=auth \
|
||||||
|
--exclude-schema=storage \
|
||||||
|
--exclude-schema=realtime \
|
||||||
|
--exclude-schema=supabase_functions \
|
||||||
|
--exclude-schema=graphql \
|
||||||
|
--exclude-schema=graphql_public \
|
||||||
|
--exclude-schema=pgsodium \
|
||||||
|
--exclude-schema=pgsodium_masks \
|
||||||
|
--exclude-schema=extensions \
|
||||||
|
--exclude-schema=pgbouncer \
|
||||||
|
--exclude-schema=supabase_migrations \
|
||||||
|
--exclude-schema=net \
|
||||||
|
--exclude-schema=vault \
|
||||||
|
--file=supabase/captured/captured_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Captured schema is ~540KB, 65 tables, 252 functions.
|
||||||
|
|
||||||
|
## Capture Data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_dump \
|
||||||
|
--host=aws-1-us-east-1.pooler.supabase.com \
|
||||||
|
--port=5432 \
|
||||||
|
--username=postgres.wnzkhezyhnfzhkhiflrp \
|
||||||
|
--dbname=postgres \
|
||||||
|
--data-only \
|
||||||
|
--no-owner \
|
||||||
|
--no-privileges \
|
||||||
|
--no-acl \
|
||||||
|
--disable-triggers \
|
||||||
|
--exclude-schema=auth \
|
||||||
|
--exclude-schema=storage \
|
||||||
|
--exclude-schema=realtime \
|
||||||
|
--exclude-schema=supabase_functions \
|
||||||
|
--exclude-schema=graphql \
|
||||||
|
--exclude-schema=graphql_public \
|
||||||
|
--exclude-schema=pgsodium \
|
||||||
|
--exclude-schema=pgsodium_masks \
|
||||||
|
--exclude-schema=extensions \
|
||||||
|
--exclude-schema=pgbouncer \
|
||||||
|
--exclude-schema=supabase_migrations \
|
||||||
|
--exclude-schema=net \
|
||||||
|
--exclude-schema=vault \
|
||||||
|
--file=supabase/captured/captured_data.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Captured data is ~130KB for this project's current size.
|
||||||
|
|
||||||
|
## Restore to Local DB (PostgreSQL 16)
|
||||||
|
|
||||||
|
PostgreSQL 16 doesn't support `transaction_timeout` (added in PG 17) and
|
||||||
|
doesn't have the `supabase_vault` extension. Pre-create the stub objects:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PGPASSWORD=routecommerce_dev_password
|
||||||
|
psql -U routecommerce -h 127.0.0.1 -d route_commerce <<'SQL'
|
||||||
|
DROP SCHEMA public CASCADE;
|
||||||
|
CREATE SCHEMA public;
|
||||||
|
GRANT ALL ON SCHEMA public TO routecommerce;
|
||||||
|
|
||||||
|
-- extensions schema (referenced by dump as extensions.uuid_generate_v4())
|
||||||
|
CREATE SCHEMA extensions;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA extensions;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto" SCHEMA extensions;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" SCHEMA extensions;
|
||||||
|
|
||||||
|
-- Stub functions in extensions schema (real Supabase has pgcrypto.crypt etc.)
|
||||||
|
CREATE OR REPLACE FUNCTION extensions.uuid_generate_v4()
|
||||||
|
RETURNS uuid LANGUAGE sql AS $$ SELECT gen_random_uuid(); $$;
|
||||||
|
CREATE OR REPLACE FUNCTION extensions.gen_salt(text)
|
||||||
|
RETURNS text LANGUAGE sql AS $$ SELECT '$2a$06$' || repeat('A', 53); $$;
|
||||||
|
CREATE OR REPLACE FUNCTION extensions.crypt(text, text)
|
||||||
|
RETURNS text LANGUAGE sql AS $$ SELECT $1; $$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA extensions TO routecommerce;
|
||||||
|
|
||||||
|
-- auth stub (we excluded auth schema from dump, but RLS policies reference auth.uid())
|
||||||
|
CREATE SCHEMA IF NOT EXISTS auth;
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT,
|
||||||
|
raw_user_meta_data JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE OR REPLACE FUNCTION auth.uid() RETURNS UUID
|
||||||
|
LANGUAGE sql STABLE AS $$ SELECT NULL::UUID; $$;
|
||||||
|
GRANT USAGE ON SCHEMA auth TO routecommerce;
|
||||||
|
GRANT ALL ON auth.users TO routecommerce;
|
||||||
|
GRANT EXECUTE ON FUNCTION auth.uid() TO routecommerce;
|
||||||
|
|
||||||
|
-- Drop Supabase-specific publications (we don't have realtime / vault)
|
||||||
|
DROP PUBLICATION IF EXISTS supabase_realtime;
|
||||||
|
DROP PUBLICATION IF EXISTS supabase_realtime_messages_publication;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Strip the PG17-only `SET transaction_timeout` line from the dump:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i 's/^SET transaction_timeout = 0;$/-- transaction_timeout requires PG17 (we are on PG16); skipped/' \
|
||||||
|
supabase/captured/captured_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply schema and data (idempotent — re-runs are safe):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -U routecommerce -h 127.0.0.1 -d route_commerce -v ON_ERROR_STOP=0 \
|
||||||
|
-f supabase/captured/captured_schema.sql 2>&1 | tail -20
|
||||||
|
|
||||||
|
psql -U routecommerce -h 127.0.0.1 -d route_commerce -v ON_ERROR_STOP=0 \
|
||||||
|
-f supabase/captured/captured_data.sql 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Most errors on re-run are "already exists" — that's expected because the
|
||||||
|
dump is idempotent for CREATE statements (we used `IF NOT EXISTS` where
|
||||||
|
possible, but pg_dump doesn't add it for everything).
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -U routecommerce -h 127.0.0.1 -d route_commerce -c "SELECT count(*) FROM pg_tables WHERE schemaname='public';"
|
||||||
|
# Expect: 65
|
||||||
|
|
||||||
|
psql -U routecommerce -h 127.0.0.1 -d route_commerce -c "SELECT count(*) FROM pg_proc WHERE pronamespace=(SELECT oid FROM pg_namespace WHERE nspname='public');"
|
||||||
|
# Expect: ~250+
|
||||||
|
|
||||||
|
psql -U routecommerce -h 127.0.0.1 -d route_commerce -c "SELECT name, slug, created_at FROM brands ORDER BY created_at;"
|
||||||
|
# Expect 5 brands: Tuxedo Corn, Indian River Direct, Sunrise Farms, Green Valley Organics, Orchard Fresh
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Didn't Work (don't try these)
|
||||||
|
|
||||||
|
| Approach | Why it failed |
|
||||||
|
|---|---|
|
||||||
|
| `psql ... db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` | Hostname is IPv6-only, dev box has no IPv6 |
|
||||||
|
| `aws-0-us-east-1.pooler.supabase.com` | "tenant/user not found" — wrong region (project is on `aws-1`) |
|
||||||
|
| `pg_dump 16.x` against PG 17 server | "server version mismatch" — fatal error |
|
||||||
|
| `supabase db dump --linked` | Requires Docker (we don't have it) |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The captured data includes 3 test brands (Sunrise, Green Valley, Orchard)
|
||||||
|
created on 2026-06-03 — these were test data the user added during the
|
||||||
|
migration work, not real customers. They can be deleted safely.
|
||||||
|
- Tuxedo Corn and Indian River Direct are the real production brands.
|
||||||
|
- The schema dump is committed to git at `supabase/captured/captured_schema.sql.gz`
|
||||||
|
for reproducibility. The data dump is gitignored (regenerate as needed).
|
||||||
|
- After dump, the local PostgREST needs a schema cache reload — restart
|
||||||
|
the postgrest process or it'll serve stale metadata for ~30 seconds.
|
||||||
|
|
||||||
|
## Post-restore: clean up test brands
|
||||||
|
|
||||||
|
The captured production data includes 3 test brands with hardcoded
|
||||||
|
sequential UUIDs (`a1b2c3d4-…`, `b2c3d4e5-…`, `c3d4e5f6-…`) created
|
||||||
|
during the migration work. They have no related rows in any of the 38
|
||||||
|
tables that FK to `brands.id`, so deletion is safe (CASCADE / NO ACTION
|
||||||
|
on zero rows is a no-op).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
BEGIN;
|
||||||
|
DELETE FROM brands
|
||||||
|
WHERE slug IN ('sunrise-farms', 'green-valley', 'orchard-fresh');
|
||||||
|
-- expect DELETE 3
|
||||||
|
COMMIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the real brands remain:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT name, slug, plan_tier FROM brands ORDER BY created_at;
|
||||||
|
-- Tuxedo Corn | tuxedo | starter
|
||||||
|
-- Indian River Direct | indian-river-direct | starter
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migrating Brand Assets (Supabase Storage → MinIO)
|
||||||
|
|
||||||
|
Brand logos and other Storage files are NOT in the Postgres dump. The
|
||||||
|
storage layer is now MinIO (S3-compatible) instead of Supabase Storage.
|
||||||
|
|
||||||
|
### Download assets from Supabase
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make sure the target buckets exist in MinIO (one-time)
|
||||||
|
mc mb --ignore-existing local/brand-logos local/videos \
|
||||||
|
local/product-images local/contacts-imports \
|
||||||
|
local/water-photos
|
||||||
|
|
||||||
|
# Pull each asset via the public URL. Path is <bucket>/<key> after the
|
||||||
|
# /storage/v1/object/public/ prefix in the Supabase URL.
|
||||||
|
mkdir -p .data/assets
|
||||||
|
BRAND_ID="<your-brand-uuid>"
|
||||||
|
for fname in logo.png olathe-sweet-logo.png olathe-sweet-logo-dark.png; do
|
||||||
|
curl -sf -o ".data/assets/$fname" \
|
||||||
|
"https://<project-ref>.supabase.co/storage/v1/object/public/brand-logos/$BRAND_ID/$fname"
|
||||||
|
mc cp ".data/assets/$fname" "local/brand-logos/$BRAND_ID/$fname"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Point the DB at MinIO (portable /storage/... paths)
|
||||||
|
|
||||||
|
After capture, the `brand_settings.logo_url` etc. values still point at
|
||||||
|
the Supabase URL. Replace the base with a relative `/storage/` path so
|
||||||
|
the Next.js rewrite in `next.config.ts` can route to whichever MinIO
|
||||||
|
endpoint is configured per environment (dev → `localhost:9000`, prod →
|
||||||
|
`storage.route.crispygoat.com`).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE brand_settings
|
||||||
|
SET
|
||||||
|
logo_url = REPLACE(logo_url, 'https://<project-ref>.supabase.co/storage/v1/object/public', '/storage'),
|
||||||
|
logo_url_dark = REPLACE(logo_url_dark, 'https://<project-ref>.supabase.co/storage/v1/object/public', '/storage'),
|
||||||
|
olathe_sweet_logo_url = REPLACE(olathe_sweet_logo_url, 'https://<project-ref>.supabase.co/storage/v1/object/public', '/storage'),
|
||||||
|
olathe_sweet_logo_url_dark = REPLACE(olathe_sweet_logo_url_dark, 'https://<project-ref>.supabase.co/storage/v1/object/public', '/storage'),
|
||||||
|
hero_image_url = REPLACE(hero_image_url, 'https://<project-ref>.supabase.co/storage/v1/object/public', '/storage');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why a rewrite instead of pointing at MinIO directly
|
||||||
|
|
||||||
|
Next.js's image optimizer (`/_next/image?url=...`) refuses to fetch
|
||||||
|
upstream images whose hostname resolves to a private IP. Local MinIO is
|
||||||
|
on `127.0.0.1` / `::1`, so URLs like `http://localhost:9000/...` get
|
||||||
|
blocked:
|
||||||
|
|
||||||
|
```
|
||||||
|
⨯ upstream image http://localhost:9000/... resolved to private ip
|
||||||
|
["::1","127.0.0.1"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The rewrite in `next.config.ts` resolves `/storage/*` to the configured
|
||||||
|
MinIO base URL server-side, so the browser sees a same-origin URL and
|
||||||
|
the optimizer's private-IP check is bypassed:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async rewrites() {
|
||||||
|
const storageBase = process.env.STORAGE_PUBLIC_URL || "http://localhost:9000";
|
||||||
|
return [{ source: "/storage/:path*", destination: `${storageBase}/:path*` }];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For production, set `STORAGE_PUBLIC_URL` to the public MinIO endpoint
|
||||||
|
(e.g., `https://storage.route.crispygoat.com`) and the same DB values
|
||||||
|
work without modification.
|
||||||
|
|
||||||
|
### Hardcoded brand asset URLs in client components
|
||||||
|
|
||||||
|
A few components used `publicUrl(BUCKETS.BRAND_LOGOS, ...)` to build a
|
||||||
|
MinIO URL at module load (used as a fallback before client-side data
|
||||||
|
loads). Switch these to `/storage/...` paths so the rewrite covers them:
|
||||||
|
|
||||||
|
- `src/components/storefront/TuxedoVideoHero.tsx` — hero video + Olathe Sweet dark logo
|
||||||
|
- `src/components/time-tracking/TimeTrackingFieldClient.tsx` — field UI logo
|
||||||
|
- `src/app/tuxedo/about/page.tsx` — about page logo
|
||||||
|
|
||||||
|
`src/lib/email-service.ts` should keep using `publicUrl(...)` because
|
||||||
|
Resend fetches the URL server-side from its own network — relative
|
||||||
|
paths won't work for emails.
|
||||||
@@ -12,7 +12,7 @@ A **multi-tenant B2B produce distribution platform**, not a SaaS starter. Build
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Migrations | **135 SQL files** (production schema, RLS, SECURITY DEFINER RPCs, audit logs) |
|
| Migrations | **135 SQL files** (production schema, RLS, SECURITY DEFINER RPCs, audit logs) |
|
||||||
| Source | **544 files** (362 .tsx + 179 .ts) |
|
| Source | **544 files** (362 .tsx + 179 .ts) |
|
||||||
| Admin pages | **87 routes** across orders, products, stops, wholesale, communications, time-tracking, water-log, route-trace, reports, advanced |
|
| Admin pages | **88 routes** across orders, products, stops, wholesale, communications, time-tracking, water-log, route-trace, reports, advanced, command-center |
|
||||||
| API routes | **60+ endpoints** (Stripe + Square + FedEx + Resend + Twilio + 8 AI endpoints) |
|
| API routes | **60+ endpoints** (Stripe + Square + FedEx + Resend + Twilio + 8 AI endpoints) |
|
||||||
| Public surfaces | 2 brand storefronts (Tuxedo, Indian River Direct) with brand theming, hero, color customization |
|
| Public surfaces | 2 brand storefronts (Tuxedo, Indian River Direct) with brand theming, hero, color customization |
|
||||||
| Integrations | Stripe (Connect + subscriptions), Square (POS sync), FedEx (rates + labels), Resend (email), Twilio (SMS), OpenAI (8 AI tools) |
|
| Integrations | Stripe (Connect + subscriptions), Square (POS sync), FedEx (rates + labels), Resend (email), Twilio (SMS), OpenAI (8 AI tools) |
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,121 +0,0 @@
|
|||||||
# Admin Redesign — Task Checklist
|
|
||||||
|
|
||||||
> Working branch: `design/ui-revamp-2026-06`
|
|
||||||
> Spec: `docs/superpowers/specs/2026-06-17-admin-redesign.md`
|
|
||||||
> Started: 2026-06-17
|
|
||||||
> Deadline: tomorrow
|
|
||||||
|
|
||||||
## How to use this file
|
|
||||||
|
|
||||||
- `[ ]` unchecked → not done · `[x]` checked → done
|
|
||||||
- Tick a box by changing `[ ]` to `[x]`
|
|
||||||
- Add free-form notes in the **Notes** column
|
|
||||||
- One commit per task (or per cluster of related tasks)
|
|
||||||
- Revert a task with `git reset --hard <commit-before-task>`
|
|
||||||
- Full revert: `git checkout main && git branch -D design/ui-revamp-2026-06`
|
|
||||||
|
|
||||||
## Revert cheatsheet
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git status # what's dirty
|
|
||||||
git stash # save uncommitted, discard
|
|
||||||
git diff # see what's staged
|
|
||||||
git log --oneline -10 # see recent commits
|
|
||||||
git reset --hard <sha> # nuke back to that commit
|
|
||||||
git checkout main # abandon branch, keep work
|
|
||||||
git branch -D design/ui-revamp-2026-06 # delete the branch
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 0 — Setup
|
|
||||||
|
|
||||||
| Done | Task | Files | Notes | Commit |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| [x] | Create feature branch | — | `design/ui-revamp-2026-06` off `main` | — |
|
|
||||||
| [x] | Write design spec | `docs/superpowers/specs/2026-06-17-admin-redesign.md` | aesthetic, color, IA, phases | — |
|
|
||||||
| [x] | Write this checklist | `docs/superpowers/plans/2026-06-17-admin-redesign-checklist.md` | — | — |
|
|
||||||
| [ ] | Commit baseline | both files above | `chore: design spec + checklist` | (will commit after writing this) |
|
|
||||||
|
|
||||||
## Phase 1 — Foundation (color + design tokens)
|
|
||||||
|
|
||||||
| Done | Task | Files | Notes | Commit |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| [x] | Replace `--admin-*` color tokens in admin-design-system.css | `src/styles/admin-design-system.css` | use spec §3 table. Drop the muddy `aba278` warning, the purple stat icons, the blue stat icons. | `style(admin): unify color tokens` (18fb44e) |
|
|
||||||
| [x] | Update `globals.css` if any admin colors live there | `src/app/globals.css` | grep for `admin-accent`, `admin-bg` | (same commit) |
|
|
||||||
| [x] | Remove inline `#fef3c7` / `#dbeafe` / `#f3e8ff` from stat cards in DashboardClient | `src/components/admin/DashboardClient.tsx` | semantic colors only | (same commit) |
|
|
||||||
|
|
||||||
## Phase 2 — Discoverability (sidebar + command palette)
|
|
||||||
|
|
||||||
| Done | Task | Files | Notes | Commit |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| [x] | Create `SideNavGroup` component (label + items + active highlight) | `src/components/admin/SideNavGroup.tsx` (new) | matches new IA: Workspace / Operations / Communications / Growth / Tracking / Insights / Settings | (4ded68c) |
|
|
||||||
| [x] | Refactor `AdminSidebar` to use grouped nav | `src/components/admin/AdminSidebar.tsx` | replace flat `NAV_ITEMS` with groups; keep role-gating, brand selector, sign-out | `feat(admin): grouped sidebar IA` (4ded68c) |
|
|
||||||
| [x] | Create `CommandPalette` (Cmd+K) | `src/components/admin/CommandPalette.tsx` (new) | global search across pages + recent items + quick actions; mounts in admin layout | `feat(admin): command palette` (750efdd) |
|
|
||||||
| [x] | Mount `CommandPalette` in admin layout | `src/app/admin/layout.tsx` | wraps children, listens for `Cmd/Ctrl+K` | (442c16d) |
|
|
||||||
| [x] | Plumb `enabledAddons` to sidebar (gates Water Log / Route Trace) | `src/app/admin/layout.tsx` | `getEnabledAddons(activeBrandId)` from `actions/billing/stripe-portal` | (442c16d) |
|
|
||||||
| [ ] | Settings pages get a top tab bar (no more hidden sub-nav) | `src/app/admin/settings/*/page.tsx` | General / Brand / Billing / Users / Integrations / Payments / Shipping / Add-ons | `feat(admin): settings top tabs` |
|
|
||||||
|
|
||||||
## Phase 3 — Component patterns
|
|
||||||
|
|
||||||
| Done | Task | Files | Notes | Commit |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| [x] | Extract `KPIStat` from DashboardClient | `src/components/admin/KPIStat.tsx` (new) | label + value + optional trend | `feat(admin): KPIStat component` (8e93734) |
|
|
||||||
| [x] | Tighten `AdminBadge` variants (status / tier / addon) | `src/components/admin/design-system/AdminBadge.tsx` | semantic: success / warning / danger / info / neutral | (8e93734) |
|
|
||||||
| [x] | Add `EmptyState` admin variant | `src/components/admin/EmptyState.tsx` (new) | icon + title + body + CTA; matches color tokens | (8e93734) |
|
|
||||||
| [x] | Add `LoadingState` admin variant | `src/components/admin/LoadingState.tsx` (new) | uses `.ha-skeleton` | (8e93734) |
|
|
||||||
| [ ] | Audit `PageHeader` usage; fix inconsistencies | `src/components/admin/design-system/PageHeader.tsx` + callers | eyebrow + title + subtitle + actions; consistent across all pages | `chore(admin): PageHeader audit` |
|
|
||||||
|
|
||||||
## Phase 4 — Dashboard (unified command center)
|
|
||||||
|
|
||||||
| Done | Task | Files | Notes | Commit |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| [ ] | Redesign `DashboardClient` as single feed (no 4 tabs) | `src/components/admin/DashboardClient.tsx` | top: KPI strip · middle: "Needs attention" feed · bottom: section grid | `feat(admin): unified dashboard` |
|
|
||||||
| [ ] | Add a "What needs attention" feed (orders pending, stops today, low stock, unanswered contacts) | same file | data from existing actions | (same commit) |
|
|
||||||
|
|
||||||
## Phase 5 — Apply patterns to top pages
|
|
||||||
|
|
||||||
| Done | Task | Files | Notes | Commit |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| [x] | `/admin/orders` list + detail | `src/app/admin/orders/page.tsx`, `[id]/page.tsx`, `AdminOrdersPanel.tsx` | new PageHeader, StatusPill, EmptyState | `feat(admin): apply design system to Orders` (467f7e6) |
|
|
||||||
| [x] | `/admin/products` list + new + edit | `src/app/admin/products/page.tsx`, `new/page.tsx`, `[id]/page.tsx`, `ProductsClient.tsx` | same | `feat(admin): apply new design system to Products pages` (1e0e278) |
|
|
||||||
| [x] | `/admin/stops` calendar | `src/app/admin/stops/page.tsx`, `StopsCalendarClient.tsx` | same | `feat(admin): apply design system to Stops pages` (685a126) |
|
|
||||||
| [ ] | `/admin/communications` composer | `src/components/admin/HarvestReach/CampaignComposerPage.tsx` | same | `style(admin): comms composer` |
|
|
||||||
| [ ] | `/admin/settings` index + tab bar | `src/app/admin/settings/page.tsx` | overview of all settings w/ quick links | `style(admin): settings index` |
|
|
||||||
|
|
||||||
## Phase 6 — Frontend polish (public side)
|
|
||||||
|
|
||||||
| Done | Task | Files | Notes | Commit |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| [ ] | Tuxedo home polish | `src/app/tuxedo/page.tsx` + `tuxedo/` components | rhythm, spacing, mobile | `style(storefront): tuxedo polish` |
|
|
||||||
| [ ] | Indian River Direct home polish | `src/app/indian-river-direct/page.tsx` + components | rhythm, spacing, mobile | `style(storefront): IRD polish` |
|
|
||||||
| [ ] | Pricing page polish | `src/app/pricing/page.tsx` | tier card spacing | `style(public): pricing polish` |
|
|
||||||
|
|
||||||
## Phase 7 — Verify (gate before merge)
|
|
||||||
|
|
||||||
| Done | Task | Command | Notes | Commit |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| [ ] | Type-check clean | `npx tsc --noEmit` | 0 errors | (no commit, gate) |
|
|
||||||
| [ ] | Build clean | `npm run build` | green | (no commit, gate) |
|
|
||||||
| [ ] | Dev server boots | `npm run dev` | `/admin` renders, sidebar works, Cmd+K opens | (no commit, gate) |
|
|
||||||
| [ ] | Manual smoke: dashboard | `localhost:4000/admin` | layout intact, no console errors | (no commit, gate) |
|
|
||||||
| [ ] | Manual smoke: command palette | `Cmd+K` | opens, type, jump | (no commit, gate) |
|
|
||||||
| [ ] | Manual smoke: orders list | `/admin/orders` | loads, page header consistent | (no commit, gate) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Live notes (free-form)
|
|
||||||
|
|
||||||
Add anything you want to remember as we go.
|
|
||||||
|
|
||||||
- The existing `.ha-*` (Harvest Almanac) class system is good. Build on it; don't replace it.
|
|
||||||
- The sidebar is in `src/components/admin/AdminSidebar.tsx` (~520 lines). Don't rewrite from scratch — refactor the data structure to groups and let the JSX mostly stand.
|
|
||||||
- `lucide-react` is already a dependency. New icons should use lucide, not inline SVGs.
|
|
||||||
- The `Atelier des Récoltes` CSS classes (`.atelier-*` in `globals.css`) are public-side only. Don't mix them into admin.
|
|
||||||
- Per MEMORY.md: spawn sub-agents sequentially if TPM rate limit hits. If parallel works, fine; if not, fall back.
|
|
||||||
|
|
||||||
## Decisions log
|
|
||||||
|
|
||||||
Record any calls you make mid-flight that change the spec.
|
|
||||||
|
|
||||||
- (none yet)
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
# Production DB Schema Migration Reliability Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:systematic-debugging (completed root cause), superpowers:writing-plans (this), superpowers:test-driven-development where code changes have tests, superpowers:verification-before-completion, and superpowers:executing-plans or subagent-driven-development to implement task-by-task. Steps use checkbox syntax.
|
|
||||||
|
|
||||||
**Goal:** Ensure that every production deploy (push to main) results in the full schema from `db/migrations/0001_init.sql` (including the `admin_users` and `admin_user_brands` tables required by `getAdminUser()`) being present in the `DATABASE_URL` that the running app connects to via its pool / drizzle client. Eliminate "relation does not exist" errors and the resulting Access Denied screen for properly provisioned Neon Auth users.
|
|
||||||
|
|
||||||
**Architecture:**
|
|
||||||
- Make the CI "Run migrations" step a hard gate (fatal on failure, plus explicit post-migrate verification that critical tables exist).
|
|
||||||
- Ship the minimal migration runner + SQL files as part of the deploy artifact so the target server has a recovery path.
|
|
||||||
- Add a lightweight post-deploy / startup verification in the app or deploy script (fail fast with clear message instead of silent 500s on first admin request).
|
|
||||||
- Keep the "migrate from full source locally" path working for initial prod DB bootstrap and emergencies.
|
|
||||||
- Do not change the core migration logic or 0001_init.sql in this plan (that would be a separate architectural change if the double-BEGIN wrapping proves fragile).
|
|
||||||
|
|
||||||
**Tech Stack:** Gitea Actions (YAML), Node 22 + pg + drizzle on target, Next.js standalone output, pm2 on Ubuntu server, Neon Postgres (with neon_auth schema).
|
|
||||||
|
|
||||||
**Root Cause (from systematic-debugging Phase 1):** The prod runtime DB lacked the `admin_users` table because (1) the migration step in `.gitea/workflows/deploy.yml` used `|| echo` making any failure (connection, SQL error in the huge 0001 file, FK to neon_auth.user, tx nesting from the file's BEGIN + script's BEGIN) non-fatal, (2) only `.next/`, `public/`, `package.json` (and optional next.config) are scp'd — `scripts/migrate.js` and `db/migrations/` are never on the server, (3) no verification after "migrate" or at app startup that the tables the admin permission layer depends on actually exist, (4) the `.env.production` written from the same secret as the CI migrate step was used, but the apply didn't happen or was skipped due to _migrations state or partial rollback.
|
|
||||||
|
|
||||||
**Evidence Gathered:**
|
|
||||||
- deploy.yml: Run migrations step, limited scp, .env.production printf, pm2 "npm start".
|
|
||||||
- scripts/migrate.js: dotenv .env.local + env override, _migrations tracking, per-file client.query(sql) inside script tx, re-throw on error.
|
|
||||||
- db/migrations/0001_init.sql: explicit CREATE TABLE admin_users (with FK to neon_auth.user), admin_user_brands, brands; file starts with BEGIN;.
|
|
||||||
- db/client.ts + src/lib/admin-permissions.ts: withPlatformAdmin → drizzle select on adminUsers from schema (the exact query that 42P01s).
|
|
||||||
- next.config.ts: output: 'standalone' (explains pm2 warning).
|
|
||||||
- Runtime logs: the repeated "Database query failed" + "relation does not exist", app starts fine.
|
|
||||||
- Git history: recent deploy "fixes" focused on SSH/env writing, not migration reliability.
|
|
||||||
|
|
||||||
**Files to touch (decomposition by responsibility):**
|
|
||||||
- `.gitea/workflows/deploy.yml` (CI pipeline gates + artifact contents)
|
|
||||||
- `scripts/migrate.js` (minor hardening if needed for verification hook)
|
|
||||||
- `src/app/api/health/route.ts` or similar (new, for startup/schema check — or add to existing)
|
|
||||||
- `CLAUDE.md` + `PRODUCTION_DEPLOYMENT_CHECKLIST.md` (docs)
|
|
||||||
- Possibly a small `scripts/verify-prod-schema.js` helper
|
|
||||||
|
|
||||||
---
|
|
||||||
### Task 1: Make CI migration step a hard failure + add explicit verification for admin_users
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `.gitea/workflows/deploy.yml:23-27` (the Run migrations step and surrounding)
|
|
||||||
|
|
||||||
- [ ] **Step 1.1:** Replace the non-fatal migration line with a strict block that fails the job if migrate fails or the critical table is missing after.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Run migrations
|
|
||||||
env:
|
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
echo "=== Running migrations against prod DB ==="
|
|
||||||
npm run migrate:one
|
|
||||||
echo "=== Verifying critical schema (admin_users) ==="
|
|
||||||
node -e '
|
|
||||||
const {Client} = require("pg");
|
|
||||||
const c = new Client({connectionString: process.env.DATABASE_URL});
|
|
||||||
c.connect().then(() => c.query("SELECT 1 FROM admin_users LIMIT 1")).then(() => {
|
|
||||||
console.log("✓ admin_users table exists");
|
|
||||||
return c.end();
|
|
||||||
}).then(() => process.exit(0)).catch(e => {
|
|
||||||
console.error("✗ admin_users missing or inaccessible:", e.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 1.2:** Run a local simulation or note that the Gitea runner will now fail the whole deploy if the secret DB is missing the table (good — forces the bootstrap to happen before code that depends on it ships).
|
|
||||||
|
|
||||||
- [ ] **Step 1.3:** Commit the yml change with message referencing the root cause (missing table in prod due to masked migration).
|
|
||||||
|
|
||||||
### Task 2: Ship migration capability in the deploy artifact so server has a recovery path
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `.gitea/workflows/deploy.yml` in the "Deploy" step (the scp and ssh sections)
|
|
||||||
|
|
||||||
- [ ] **Step 2.1:** Add scp for the migration assets (after the existing public/.next scp):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo "Copying migration runner and SQL..."
|
|
||||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r scripts/migrate.js tyler@...:$APP_DIR/scripts/ || true
|
|
||||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r db/migrations tyler@...:$APP_DIR/db/ || true
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2.2:** Update the server ssh install/restart line to also ensure the scripts dir has the right perms if needed, but mainly document that now `node scripts/migrate.js` will work on the server with the .env.production.
|
|
||||||
|
|
||||||
- [ ] **Step 2.3:** In the same Deploy step, after writing .env.production and before or after the pm2 restart, optionally run the migrate on the server as a belt-and-suspenders (using the just-written .env):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh ... "cd $APP_DIR && source .env.production 2>/dev/null || export \$(grep DATABASE_URL .env.production); node scripts/migrate.js || echo 'migrate on server completed or not needed'"
|
|
||||||
```
|
|
||||||
|
|
||||||
(Keep it non-fatal on server for now; the CI gate is the hard one.)
|
|
||||||
|
|
||||||
- [ ] **Step 2.4:** Test the scp paths in a dry-run or note the change.
|
|
||||||
|
|
||||||
### Task 3: Add a minimal runtime / startup guard (fail fast with clear message)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/app/api/health/db-schema/route.ts` (or add to an existing health if present)
|
|
||||||
- Or simpler: in the admin layout or a top level, but a dedicated health is better for PM2/docker.
|
|
||||||
|
|
||||||
- [ ] **Step 3.1:** Create a tiny health endpoint that does the same check the CI verification does (SELECT 1 FROM admin_users) using the existing pool or withDb, returns 200 or 503 with message "Schema not applied - run migrations".
|
|
||||||
|
|
||||||
- [ ] **Step 3.2:** Wire it so the deploy can curl it after restart as a final gate (in the workflow ssh step).
|
|
||||||
|
|
||||||
- [ ] **Step 3.3:** (Optional but recommended per defense-in-depth) Call a similar check early in getAdminUser or the admin layout and log a very loud message + return a better error than generic "does not have admin access" when the table is literally missing.
|
|
||||||
|
|
||||||
### Task 4: Update documentation and bootstrap instructions (so humans know the right sequence)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `CLAUDE.md` (the Commands and Important File Locations + Gotchas sections)
|
|
||||||
- Modify or create: `PRODUCTION_DEPLOYMENT_CHECKLIST.md` or a new `docs/PROD_BOOTSTRAP.md`
|
|
||||||
|
|
||||||
- [ ] **Step 4.1:** In CLAUDE.md under "Commands" and "Adding a New Brand" / auth section, add a "First production deploy / new prod DB bootstrap" subsection:
|
|
||||||
|
|
||||||
1. Ensure the Neon project has neon_auth enabled and the DATABASE_URL secret in Gitea points to it.
|
|
||||||
2. (Before first code push that depends on admin) Locally or in a throwaway runner: `DATABASE_URL=prod... node scripts/migrate.js`
|
|
||||||
3. Then `DATABASE_URL=prod... npx tsx scripts/provision-admin.ts you@real.com platform_admin` (after signing in on the prod URL).
|
|
||||||
4. Push; the CI gate + shipped runner will keep it healthy on future deploys.
|
|
||||||
5. If you ever see "relation admin_users does not exist" in prod logs, the DB the app is talking to is not the one that had migrate run.
|
|
||||||
|
|
||||||
- [ ] **Step 4.2:** Add a note about the `|| echo` anti-pattern that was removed and why the new verification step exists.
|
|
||||||
|
|
||||||
- [ ] **Step 4.3:** Mention the standalone vs npm start issue (already in logs) and that the start command on server should eventually be updated to `node .next/standalone/server.js -p 3100` (can be a follow-up task).
|
|
||||||
|
|
||||||
### Task 5: Verification before claiming success (use the dedicated skill)
|
|
||||||
|
|
||||||
**Files:** (none new, just process)
|
|
||||||
|
|
||||||
- [ ] **Step 5.1:** Before merging the plan changes, use `superpowers:verification-before-completion` checklist: the change makes a fresh DB get the table, an "already applied" DB is a no-op, a deploy with missing table now fails the job early with clear output, a manual server migrate works because the files are there, the runtime health returns 200 when table present.
|
|
||||||
|
|
||||||
- [ ] **Step 5.2:** After the PR is on a branch, trigger a deploy to a staging or the real prod (with a test DB first if possible), capture the CI log showing the new verification passing, and the app logs showing no more "Database query failed" on /admin.
|
|
||||||
|
|
||||||
- [ ] **Step 5.3:** Run the provision script as the final user-visible test; confirm the Access Denied with email message is gone and the platform_admin can see the UI.
|
|
||||||
|
|
||||||
- [ ] **Step 5.4:** Document the before/after in the plan or a memory file.
|
|
||||||
|
|
||||||
### Task 6: (Stretch / follow-up) Improve the migrate script's resilience for huge init files (if the double tx ever bites again)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `scripts/migrate.js`
|
|
||||||
|
|
||||||
- [ ] Only if during verification the 0001 apply is flaky: change the per-file execution to not wrap the file's own BEGIN/COMMIT, or use a separate connection, or exec `psql -f` (but keep node/pg for consistency). Add a comment explaining the previous fragility.
|
|
||||||
|
|
||||||
**Rollback / emergency:** If a deploy breaks because of this, the server now has the scripts + db/migrations copied, so SSH + `source .env.production; node scripts/migrate.js` is the recovery (exactly what the user was trying to do manually).
|
|
||||||
|
|
||||||
**Success criteria:**
|
|
||||||
- A brand new prod DB + push to main results in a green deploy + working /admin after provision.
|
|
||||||
- The error "relation \"admin_users\" does not exist" no longer appears in prod pm2 logs for normal admin flows.
|
|
||||||
- The pipeline fails loudly (with the table name in the error) instead of shipping a broken app that only shows "Access Denied".
|
|
||||||
@@ -1,470 +0,0 @@
|
|||||||
# 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,325 @@
|
|||||||
|
# Supabase → Self-Hosted Migration — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Approved
|
||||||
|
**Author:** Brainstorm session with user
|
||||||
|
**Target branch:** `selfhost/migrate` (to be created)
|
||||||
|
**Source branches:** `crispygoat/self-hosted-postgres` (6 commits) + `crispygoat/feat/better-auth` (1 commit, contains a detailed `plan.md`)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Move Route Commerce off Supabase's hosted platform onto a self-hosted stack while preserving all data and behavior. Replace Supabase Auth with `better-auth`, Supabase Storage with MinIO, and Supabase's hosted Postgres with a plain Postgres instance fronted by PostgREST (the same protocol `supabase-js` and `@supabase/ssr` already speak). Consolidate two diverging branches that have been tackling different layers of this migration independently.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **No data loss.** Full schema + data dump from the live Supabase project, restored on the new self-hosted Postgres.
|
||||||
|
2. **No band-aids.** Real self-hosted replacement, not runtime patches that paper over Supabase.
|
||||||
|
3. **App behavior unchanged.** All 137 SECURITY DEFINER RPCs, server actions, and client flows keep working with minimal code change.
|
||||||
|
4. **Single coherent path.** Merge the two in-flight branches into one, eliminate the divergence.
|
||||||
|
5. **Local + remote parity.** `docker compose up` works on the dev box and on the prod server (route.crispygoat.com).
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Replacing `supabase-js` / `@supabase/ssr` libraries. They speak PostgREST; we keep using them.
|
||||||
|
- Rewriting the 185 SECURITY DEFINER functions. They stay as-is; brand scoping is already in the function bodies / app layer.
|
||||||
|
- Changing the Stripe, Resend, Square, or any other non-Supabase integrations.
|
||||||
|
- Performance tuning of the new stack (separate follow-up).
|
||||||
|
- Adopting realtime (not used in current code) or Supabase Edge Functions (not used).
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **App must keep building** (`npx tsc --noEmit && npm run build`) without Supabase env vars.
|
||||||
|
- **Mock mode still works** for `NEXT_PUBLIC_USE_MOCK_DATA=true` deployments (demos, Netlify previews).
|
||||||
|
- **Dev mode bypass** (`dev_session=platform_admin` cookie) must continue to function for local testing.
|
||||||
|
- **No new external service dependencies** unless explicitly approved (i.e., we are not introducing Vercel Postgres, Neon, etc. — the stack is self-hosted).
|
||||||
|
- **Migration is reversible**: the Supabase project remains live until cutover is verified.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Next.js app (Node, port 3100 via PM2) │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ supabase-js│ │ better-auth│ │ S3 SDK │ │ middleware │ │
|
||||||
|
│ │ → PostgREST│ │ (in-proc) │ │ → MinIO │ │ → /login │ │
|
||||||
|
│ └─────┬──────┘ └──────┬─────┘ └──────┬───────┘ └──────────────┘ │
|
||||||
|
│ │ anon key │ uses pg.Pool │ MinIO creds │
|
||||||
|
└────────┼────────────────┼────────────────┼────────────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||||
|
│ PostgREST │ │ Postgres │ │ MinIO │
|
||||||
|
│ (port │───▶│ (port 5432)│ │ (port 9000)│
|
||||||
|
│ 3001) │ │ │ │ (S3 API) │
|
||||||
|
└────────────┘ └────────────┘ └────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Single Docker Compose stack on the prod server: `db` (Postgres 16) + `postgrest` + `minio` + `minio_init` (one-shot) + app under PM2.
|
||||||
|
|
||||||
|
## Branch & Merge Strategy
|
||||||
|
|
||||||
|
1. Create new branch `selfhost/migrate` from `main` (commit `9374e63`).
|
||||||
|
2. Cherry-pick the 7 commits from `crispygoat/self-hosted-postgres` (oldest to newest):
|
||||||
|
- `2de32b0` Add rocket emoji to tagline
|
||||||
|
- `988310f` Add Gitea Actions deploy workflow
|
||||||
|
- `8ad8dbb` Remove npm cache from workflow (local runner)
|
||||||
|
- `fddb917` Use npm install instead of npm ci (no lockfile)
|
||||||
|
- `beac3e4` Load .env.production from server before build
|
||||||
|
- `e8f2d76` Fix workflow: use actual secrets, fix env file writing
|
||||||
|
- `367a562` Add self-hosted Postgres docker-compose
|
||||||
|
3. Cherry-pick the 1 commit from `crispygoat/feat/better-auth`:
|
||||||
|
- `456b5b1` Add MinIO storage + replace Supabase Storage with S3 SDK
|
||||||
|
4. Resolve conflicts (expected in `.env.example`, `.gitea/workflows/deploy.yml`, `docker-compose.yml`).
|
||||||
|
5. Apply the spec's Phase A migration patches on top.
|
||||||
|
6. Add the `MinIO` + `minio_init` services to `docker-compose.yml` (the better-auth branch only has app code; it relies on the self-hosted branch's infra setup, but neither has the MinIO service in compose).
|
||||||
|
7. Update `src/lib/supabase.ts` line 8: change `!supabaseUrl.includes("supabase.co")` to drop that condition so the real client is used against local PostgREST (currently this triggers mock mode whenever URL is non-Supabase).
|
||||||
|
8. Update Gitea deploy workflow to bring up the Docker stack on the prod server before starting the app.
|
||||||
|
|
||||||
|
## Env Vars (consolidated)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── App ──
|
||||||
|
NODE_ENV=production
|
||||||
|
NEXT_PUBLIC_BASE_URL=https://route.crispygoat.com
|
||||||
|
PORT=3100
|
||||||
|
|
||||||
|
# ── Postgres (self-hosted) ──
|
||||||
|
POSTGRES_USER=routecommerce
|
||||||
|
POSTGRES_PASSWORD=<strong-pw>
|
||||||
|
POSTGRES_DB=route_commerce
|
||||||
|
DATABASE_URL=postgresql://routecommerce:<pw>@db:5432/route_commerce?schema=public
|
||||||
|
|
||||||
|
# ── PostgREST ──
|
||||||
|
PGRST_SERVER_PORT=3001
|
||||||
|
PGRST_DB_URI=postgresql://routecommerce:<pw>@db:5432/route_commerce
|
||||||
|
PGRST_DB_ANON_ROLE=anon
|
||||||
|
PGRST_JWT_SECRET=<random> # not used for auth; required by PostgREST
|
||||||
|
|
||||||
|
# ── better-auth ──
|
||||||
|
BETTER_AUTH_SECRET=<random>
|
||||||
|
BETTER_AUTH_URL=https://route.crispygoat.com
|
||||||
|
NEXT_PUBLIC_BETTER_AUTH_URL=https://route.crispygoat.com
|
||||||
|
|
||||||
|
# ── MinIO ──
|
||||||
|
MINIO_ROOT_USER=routecommerce
|
||||||
|
MINIO_ROOT_PASSWORD=<strong-pw>
|
||||||
|
NEXT_PUBLIC_STORAGE_BASE_URL=https://route.crispygoat.com/storage
|
||||||
|
STORAGE_ENDPOINT=http://minio:9000
|
||||||
|
STORAGE_REGION=us-east-1
|
||||||
|
STORAGE_ACCESS_KEY=routecommerce
|
||||||
|
STORAGE_SECRET_KEY=<strong-pw>
|
||||||
|
STORAGE_BUCKET_PREFIX=
|
||||||
|
|
||||||
|
# ── Supabase env vars (legacy, kept for supabase-js client) ──
|
||||||
|
# These now point at the local PostgREST instead of Supabase.
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=http://postgrest:3001 # in Docker network; localhost:3001 from host
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=<random> # PostgREST accepts any string; matches old pattern
|
||||||
|
|
||||||
|
# ── Removed ──
|
||||||
|
# SUPABASE_SERVICE_ROLE_KEY — no longer needed; better-auth uses pg.Pool directly with the routecommerce role
|
||||||
|
|
||||||
|
# ── Unchanged ──
|
||||||
|
STRIPE_SECRET_KEY=...
|
||||||
|
STRIPE_WEBHOOK_SECRET=...
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||||
|
RESEND_API_KEY=...
|
||||||
|
RESEND_WEBHOOK_SECRET=...
|
||||||
|
OPENAI_API_KEY=...
|
||||||
|
MINIMAX_API_KEY=...
|
||||||
|
MINIMAX_BASE_URL=...
|
||||||
|
FROM_EMAIL=...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
1. `/login` posts email + password to `authClient.signIn.email()` (better-auth/react).
|
||||||
|
2. better-auth validates against the `user` table in Postgres via `pg.Pool`, sets the `rc_session_token` cookie via `nextCookies()` plugin.
|
||||||
|
3. `src/middleware.ts` checks for `rc_session_token` (or `dev_session` for testing); unauthed requests redirect to `/login`.
|
||||||
|
4. `getAdminUser()` in `src/lib/admin-permissions.ts` calls `auth.api.getSession({ headers })` then `pg.Pool.query("SELECT * FROM admin_users WHERE user_id = $1")` to resolve the `AdminUser` with role + permission flags.
|
||||||
|
5. Server actions check `can_manage_*` flags as today.
|
||||||
|
|
||||||
|
### Database (RPC + table access)
|
||||||
|
|
||||||
|
1. Server action calls `supabase.rpc('foo', { p_brand_id })` using the supabase-js client from `src/lib/supabase.ts` (base URL = local PostgREST, anon key header).
|
||||||
|
2. PostgREST receives `POST /rest/v1/rpc/foo`, looks up the SECURITY DEFINER function in `pg_catalog`, executes it.
|
||||||
|
3. The function runs with the function owner's privileges (RLS is disabled, so it returns all rows; brand scoping is in the function body / `p_brand_id` filter).
|
||||||
|
4. Result returns as JSON through PostgREST to the app.
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
1. Admin uploads a product image through the admin UI.
|
||||||
|
2. Server action `uploadProductImage` reads the file as `Buffer`, calls `uploadFile({ bucket, key, body, contentType })` from `src/lib/storage.ts`.
|
||||||
|
3. `uploadFile` sends a `PutObjectCommand` to MinIO via `@aws-sdk/client-s3` (path-style, forcePathStyle for MinIO compatibility).
|
||||||
|
4. MinIO stores the object; the action saves the public URL `${NEXT_PUBLIC_STORAGE_BASE_URL}/${bucket}/${key}` to the `products.image_url` column.
|
||||||
|
5. MinIO bucket policy is set to `anonymous download` by the `minio_init` one-shot service, so public reads work without presigned URLs.
|
||||||
|
|
||||||
|
## File Changes (summary)
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
| Path | Source | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `docker-compose.yml` | `self-hosted-postgres` branch | Postgres + PostgREST + MinIO + minio_init services |
|
||||||
|
| `.env.example` | both branches (merged) | Consolidated env vars |
|
||||||
|
| `.gitea/workflows/deploy.yml` | `self-hosted-postgres` branch | Updated to bring up Docker stack on prod |
|
||||||
|
| `supabase/captured_schema.sql` | NEW | Output of `pg_dump --schema-and-data` from Supabase |
|
||||||
|
| `src/lib/auth.ts` | `feat/better-auth` branch | better-auth config (Kysely + pg.Pool) |
|
||||||
|
| `src/lib/auth-client.ts` | `feat/better-auth` branch | better-auth/react client |
|
||||||
|
| `src/lib/storage.ts` | `feat/better-auth` branch | S3 client + upload/delete/publicUrl helpers + bucket constants |
|
||||||
|
| `src/app/api/auth/[...all]/route.ts` | `feat/better-auth` branch | better-auth catch-all route |
|
||||||
|
| `supabase/migrations/000_preflight_supabase_compat.sql` | `feat/better-auth` branch | Stub `auth` schema + `anon`/`authenticated`/`service_role` roles |
|
||||||
|
| `supabase/migrations/200_better_auth_tables.sql` | `feat/better-auth` branch | better-auth's `user`/`session`/`account`/`verification` tables |
|
||||||
|
|
||||||
|
### Deleted files
|
||||||
|
|
||||||
|
| Path | Source | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| `supabase/migrations/BUNDLE_018_042.sql` | deleted in `feat/better-auth` | Concatenated duplicate of 018-042; explicit migrations apply in order |
|
||||||
|
| `supabase/migrations/XXX_*.sql` (4 files) | deleted in `feat/better-auth` | Drafts using XXX convention |
|
||||||
|
| `supabase/migrations/087_brand_logos_bucket.sql` | deleted in `feat/better-auth` | Supabase Storage replaced by MinIO |
|
||||||
|
| `supabase/migrations/099_contact_imports_bucket.sql` | deleted in `feat/better-auth` | Supabase Storage replaced by MinIO |
|
||||||
|
| `supabase/migrations/145_create_product_images_bucket.sql` | deleted in `feat/better-auth` | Supabase Storage replaced by MinIO |
|
||||||
|
| `src/lib/supabase/server.ts` | deleted in `feat/better-auth` | Replaced by better-auth catch-all route |
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
| Path | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/lib/supabase.ts` line 8 | Drop `!supabaseUrl.includes("supabase.co")` from `useMockData` check (only `NEXT_PUBLIC_USE_MOCK_DATA === "true"` should trigger mock) |
|
||||||
|
| `src/lib/admin-permissions.ts` | Use better-auth session + `pg.Pool` instead of `rc_auth_uid` cookie + Supabase REST |
|
||||||
|
| `src/middleware.ts` | Check for `rc_session_token` (better-auth) instead of `rc_auth_uid` |
|
||||||
|
| `src/actions/login.ts` | Use `authClient.signIn.email()` instead of `supabase.auth.signInWithPassword` |
|
||||||
|
| `src/actions/wholesale-auth.ts` | Use better-auth email sign-in |
|
||||||
|
| `src/actions/admin/force-login.ts` | Use `pg.Pool` for upsert (no Supabase) |
|
||||||
|
| `src/actions/brand-settings.ts` | Replace 8 Supabase fetch PUTs with `uploadFile()` |
|
||||||
|
| `src/actions/products/upload-image.ts` | Replace 3 Supabase calls with `uploadFile()` |
|
||||||
|
| `src/actions/communications/import-contacts.ts` | Replace PUT + LIST with S3 SDK |
|
||||||
|
| `src/app/api/water-photo-upload/route.ts` | Replace Supabase with `uploadFile()` |
|
||||||
|
| `src/lib/email-service.ts` | Use `publicUrl(BUCKETS.X, key)` for 4 hardcoded Supabase URLs |
|
||||||
|
| `src/components/admin/AdminHeader.tsx` | Use better-auth session |
|
||||||
|
| `src/components/admin/AdminSidebar.tsx` | Use better-auth session |
|
||||||
|
| `src/app/admin/me/AdminMeClient.tsx` | Use better-auth `authClient` for password change |
|
||||||
|
| `src/app/logout/page.tsx` | Use better-auth `authClient.signOut()` |
|
||||||
|
| `src/app/reset-password/page.tsx` | Use better-auth `authClient.changePassword()` |
|
||||||
|
| `src/components/storefront/TuxedoVideoHero.tsx` | Use `publicUrl()` for hardcoded Supabase URL |
|
||||||
|
| `src/components/time-tracking/TimeTrackingFieldClient.tsx` | Use `publicUrl()` |
|
||||||
|
| `src/app/tuxedo/about/page.tsx` | Use `publicUrl()` |
|
||||||
|
| `src/app/indian-river-direct/stops/page.tsx` | Use `publicUrl()` |
|
||||||
|
| `supabase/migrations/006_water_log_rpcs_fixed.sql` | Patch: `STATIC` → `STABLE` (6 occurrences) |
|
||||||
|
| `supabase/migrations/099_harvest_reach_segmentation.sql` | Patch: quote `time` column references |
|
||||||
|
| `supabase/migrations/135_email_automation_rpcs.sql` | Patch: reorder `enroll_abandoned_cart` params or add default to `p_next_email_at` |
|
||||||
|
| `package.json` | Add `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner`, `better-auth`, `kysely`, `pg` |
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Failure | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `pg_dump` from Supabase fails | Retry once, then halt and surface the error. Do not proceed with a partial dump. |
|
||||||
|
| Migration apply fails | Continue through all 137, collect failures in a log file, document them as a follow-up commit. |
|
||||||
|
| better-auth session invalid / expired | `getAdminUser()` returns `null`; pages redirect to `/login` via middleware. |
|
||||||
|
| PostgREST down | supabase-js calls throw; server actions return `{ success: false, error: "Database unavailable" }`. |
|
||||||
|
| MinIO down | Upload returns 500; UI shows "Upload failed, please retry." |
|
||||||
|
| `auth.uid()` returns NULL inside SECURITY DEFINER function | Per plan.md Phase D Option 1: `ALTER TABLE … DISABLE ROW LEVEL SECURITY` on all `public.*` tables after `pg_dump` apply. Functions still execute with owner privileges; brand scoping happens at the function-body / app layer. |
|
||||||
|
| RLS policies left over from `pg_dump` | Same as above: `DISABLE ROW LEVEL SECURITY` removes the block. |
|
||||||
|
|
||||||
|
## RLS Strategy (Phase D detail)
|
||||||
|
|
||||||
|
The 185 SECURITY DEFINER functions reference `auth.uid()`. The preflight stubs it to read `current_setting('request.jwt.claim.sub')`. In production this would be set by PostgREST from the JWT. Without it, `auth.uid()` returns NULL.
|
||||||
|
|
||||||
|
**Decision: Disable RLS on all `public.*` tables.**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DO $$ DECLARE r record; BEGIN
|
||||||
|
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
|
||||||
|
EXECUTE 'ALTER TABLE public.' || quote_ident(r.tablename) || ' DISABLE ROW LEVEL SECURITY';
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is consistent with the existing "brand scoping in server actions" pattern documented in CLAUDE.md. The app already threads `effectiveBrandId = brandId ?? adminUser.brand_id ?? null` to every RPC. SECURITY DEFINER functions still execute with the function owner's privileges; RLS doesn't block them when disabled. The alternative (wiring PostgREST JWT → `request.jwt.claim.sub`) is more complex and not justified by current usage.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
End-to-end verification sequence (per plan.md Phase E, expanded):
|
||||||
|
|
||||||
|
1. **DB schema + data apply cleanly.**
|
||||||
|
- Run `pg_dump --schema-and-data --no-owner --no-privileges --schema=public --exclude-schema=auth --exclude-schema=storage "postgresql://postgres:<pw>@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" -f supabase/captured_schema.sql` from the user's Mac (direct PG blocked from this dev box per MEMORY.md).
|
||||||
|
- `docker compose up -d db postgrest minio minio_init`.
|
||||||
|
- Apply preflight, captured schema, then all 137 migrations. Document any remaining failures.
|
||||||
|
- Apply RLS disable block. (The 3 patched migrations 006/099/135 are applied as part of the 137-migration batch — verify each succeeded.)
|
||||||
|
2. **PostgREST smoke.**
|
||||||
|
- `curl http://127.0.0.1:3001/brands?limit=1 -H "apikey: <anon>"` → 200.
|
||||||
|
- `curl -X POST http://127.0.0.1:3001/rpc/get_public_stops_for_brand -H "Content-Type: application/json" -d '{"p_slug":"indian-river-direct"}'` → 200 with non-empty array.
|
||||||
|
3. **MinIO buckets public.**
|
||||||
|
- `mc alias set local http://127.0.0.1:9000 <user> <pass>`.
|
||||||
|
- `mc ls local/` shows all 5 buckets.
|
||||||
|
- `curl -I http://127.0.0.1:9000/brand-logos/test.png` → 200 or 404, never 403.
|
||||||
|
4. **App build.**
|
||||||
|
- `npx tsc --noEmit && npm run build` with the new env vars. Goal: no Supabase URL parse errors, no `auth.uid()` undefined, no missing bucket errors.
|
||||||
|
5. **Auth round-trip.**
|
||||||
|
- `POST /api/auth/sign-up/email` with test email/password → 200, user created in `user` table.
|
||||||
|
- `POST /api/auth/sign-in/email` → 200, `rc_session_token` cookie set.
|
||||||
|
- Hit `/admin` with the cookie → 200, not redirect to `/login`.
|
||||||
|
6. **Storage round-trip.**
|
||||||
|
- Start dev server (`npm run dev`).
|
||||||
|
- Sign in via better-auth.
|
||||||
|
- Upload a product image via admin UI → verify it lands in MinIO (`mc ls local/product-images/`).
|
||||||
|
- Visit `/indian-river-direct/products/[slug]` → image renders with the MinIO URL.
|
||||||
|
7. **Data fidelity spot check.**
|
||||||
|
- Pick 3 known data points (a specific brand, an order, a contact) and verify they match what's in the live Supabase.
|
||||||
|
8. **Playwright E2E.**
|
||||||
|
- Update env in `playwright.config.ts` (or `.env.test`).
|
||||||
|
- `npx playwright test` passes.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Migration apply order | `pg_dump` puts everything in dependency order; applying it first resolves most cross-references. Apply preflight first to stub `auth`, then captured schema, then migrations in numeric order. |
|
||||||
|
| The 3 patched migrations are load-bearing | 006 (water-log RPCs) is critical for `/admin/water-log`; 099 (harvest reach segmentation) is critical for `/admin/communications`; 135 (email automation) is critical for abandoned cart + welcome sequence. If patches don't work, those features break. Document as follow-ups. (087 is in the deleted list since Supabase Storage is replaced by MinIO.) |
|
||||||
|
| `pg_dump` includes conflicting `auth.uid()` body | The preflight creates the stub. If `pg_dump` redefines it, apply `pg_dump` first, then re-apply preflight (CREATE OR REPLACE FUNCTION handles re-definition). |
|
||||||
|
| Existing user-uploaded images unreachable in new stack | User will re-upload brand logos and product images. The Tuxedo video + Olathe logos (referenced in `email-service.ts` and `tuxedo/about/page.tsx`) need to be copied over manually to MinIO before the cutover. |
|
||||||
|
| PostgREST connection pool | PostgREST opens ~10 connections. Default Postgres `max_connections=100` is fine. |
|
||||||
|
| `dev_session` cookie and `rc_session_token` cookie coexist | Both checked in middleware. Dev mode bypass stays functional for local testing. |
|
||||||
|
| Mock mode regression | The `useMockData` check in `src/lib/supabase.ts` line 8 currently triggers on `!supabaseUrl.includes("supabase.co")` — this would falsely trigger against `http://localhost:3001`. Fix: drop the `.includes("supabase.co")` condition; rely on `NEXT_PUBLIC_USE_MOCK_DATA` flag only. |
|
||||||
|
| Two branches had different `.env.example` | Conflict during cherry-pick. Resolution: use the union with comments labeling each section's source. |
|
||||||
|
|
||||||
|
## Implementation Phases (overview; full detail in the merged `plan.md` + new task list)
|
||||||
|
|
||||||
|
1. **Phase 0 — Merge** — Create `selfhost/migrate`, cherry-pick both branches, resolve conflicts, fix `useMockData` check, update Gitea deploy workflow.
|
||||||
|
2. **Phase A — Capture base schema** — `pg_dump` from Supabase (user's Mac), restore to local Postgres, apply preflight + captured schema + 137 migrations + RLS disable.
|
||||||
|
3. **Phase B — MinIO** — Add MinIO + minio_init services to docker-compose, install AWS SDK, configure bucket policy.
|
||||||
|
4. **Phase C — Verify end-to-end** — Run the test sequence above. Fix any issues.
|
||||||
|
5. **Phase D — Cutover** — Update the prod server's env, run the same migration on the prod Postgres, restart services, verify with smoke tests. Supabase project stays live for rollback until prod has been verified for 24h.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **Bucket name `videos` for the Tuxedo hero** — currently in `TuxedoVideoHero.tsx` as a hardcoded Supabase URL. The new `src/lib/storage.ts` has `BUCKETS.VIDEOS = "videos"`. Need to manually copy the Tuxedo hero video file to MinIO before cutover.
|
||||||
|
- **Storage URL routing** — `NEXT_PUBLIC_STORAGE_BASE_URL` is `https://route.crispygoat.com/storage`. The deploy workflow needs a reverse proxy (Caddy or nginx) in front of MinIO on the prod server, OR MinIO port 9000 must be exposed via the existing domain. Decide: add Caddy to docker-compose, or use path-based routing in the existing reverse proxy.
|
||||||
|
- **Better-auth session table cleanup** — better-auth manages its own `session` table. Existing Supabase auth users will need to re-register (no password migration) OR a one-time SQL `INSERT INTO "user" SELECT … FROM auth.users` to seed better-auth users. Decide based on how many active users exist.
|
||||||
|
|
||||||
|
## Out of Scope (explicit)
|
||||||
|
|
||||||
|
- Migrating user passwords from `auth.users` to `user.password` (better-auth). Will be handled as a one-time "reset your password" email blast in a follow-up.
|
||||||
|
- Performance tuning, indexing strategy, or connection pooling beyond defaults.
|
||||||
|
- Replacing `@supabase/ssr` (already deleted in better-auth branch) or `supabase-js` (kept for PostgREST compatibility).
|
||||||
|
- Migrating Supabase Realtime subscriptions (none in current code).
|
||||||
|
- Migrating Supabase Edge Functions (none exist).
|
||||||
|
- Changing RLS to be useful instead of disabled (current pattern: brand scoping in app + SECURITY DEFINER).
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `selfhost/migrate` branch builds cleanly (`npx tsc --noEmit && npm run build`).
|
||||||
|
- [ ] `docker compose up` on the dev box starts Postgres + PostgREST + MinIO and all 137 migrations apply.
|
||||||
|
- [ ] All 3 patched migrations (006, 099, 135) apply without errors.
|
||||||
|
- [ ] `supabase-js` calls against `http://localhost:3001` return the same shape as before against Supabase.
|
||||||
|
- [ ] better-auth sign-up + sign-in round-trip works in the browser.
|
||||||
|
- [ ] Product image upload via admin UI lands in MinIO and renders on the storefront.
|
||||||
|
- [ ] Existing data (brands, products, orders, contacts) from the live Supabase dump appears in local Postgres.
|
||||||
|
- [ ] Mock mode still works when `NEXT_PUBLIC_USE_MOCK_DATA=true`.
|
||||||
|
- [ ] Dev mode bypass (`dev_session=platform_admin` cookie) still works.
|
||||||
|
- [ ] Gitea deploy workflow brings up the Docker stack on the prod server, applies migrations, and restarts the app.
|
||||||
|
- [ ] Supabase project remains live and unchanged until prod is verified for 24h post-cutover.
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
# Admin Redesign — 2026-06-17
|
|
||||||
|
|
||||||
> Branch: `design/ui-revamp-2026-06`
|
|
||||||
> Type: Hot take. Tomorrow deadline. Approved yolo.
|
|
||||||
> Goal: polish the public side, reimagine the admin, fix the colors, make things findable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Context
|
|
||||||
|
|
||||||
- **Public side (`/`, `/tuxedo`, `/indian-river-direct`, pricing, blog, contact)** — "good, just needs polishing." The "Atelier des Récoltes" / "Field Almanac" editorial language is in place (cream + forest + gold + Fraunces + Manrope + Fragment Mono). Don't break it.
|
|
||||||
- **Admin (`/admin/*`)** — the surface that needs the most work.
|
|
||||||
- 15+ admin sections are split across 4 dashboard tabs (Operations / Fulfillment / Management / Tools). **The user can't find things.**
|
|
||||||
- Color palette mixes forest + citrus + sage + gold + warmred + surface greys. **The colors fight each other and feel muddy.**
|
|
||||||
- Sidebar has 6 flat items. "Settings" sub-pages are reachable only by knowing they exist. **Discoverability is bad.**
|
|
||||||
- The "Harvest Almanac" design system (`.ha-*` classes) exists but is underused. Most admin pages fall back to defaults.
|
|
||||||
|
|
||||||
## 2. Aesthetic direction (one paragraph, committed)
|
|
||||||
|
|
||||||
> **Editorial operations.** The admin should feel like the back office of an almanac press, not a generic SaaS dashboard. Warm cream canvas, deep botanical green for primary action, amber gold for the *one* thing you should do next, ink-black for text, a single soft beige for borders. The same typeface family as the public side (Fraunces / Manrope / Fragment Mono). Generous use of numerals in Fragment Mono so prices and counts read like a ledger. **One primary, one accent, four neutrals. That's it.** No purple, no teal, no citrus orange as a primary.
|
|
||||||
|
|
||||||
Commit to this and stop adding new colors.
|
|
||||||
|
|
||||||
## 3. Color system (final, do not deviate)
|
|
||||||
|
|
||||||
| Token | Hex | Use |
|
|
||||||
|---|---|---|
|
|
||||||
| `--admin-bg` | `#FAF7F0` | Page background (atelier cream) |
|
|
||||||
| `--admin-surface` | `#FFFFFF` | Cards, modals, sheets |
|
|
||||||
| `--admin-surface-sunken` | `#F4F1E8` | Inset wells, code blocks |
|
|
||||||
| `--admin-ink` | `#1A1814` | Primary text |
|
|
||||||
| `--admin-ink-muted` | `#73706B` | Secondary text |
|
|
||||||
| `--admin-ink-faint` | `#A8A29E` | Tertiary / placeholders |
|
|
||||||
| `--admin-line` | `#E8E4D7` | Borders, dividers |
|
|
||||||
| `--admin-line-strong` | `#D4CFBE` | Emphasized borders |
|
|
||||||
| `--admin-primary` | `#1F4D2A` | Primary action (deep botanical) |
|
|
||||||
| `--admin-primary-hover` | `#163C20` | Primary hover |
|
|
||||||
| `--admin-primary-soft` | `#E8F0E5` | Primary at 8% (selected rows, chips) |
|
|
||||||
| `--admin-accent` | `#B8761E` | Amber — "the one thing to do" |
|
|
||||||
| `--admin-accent-soft` | `#F7EBD5` | Amber at 12% |
|
|
||||||
| `--admin-success` | `#1F4D2A` | (same as primary; semantic) |
|
|
||||||
| `--admin-warning` | `#B8761E` | (same as accent; semantic) |
|
|
||||||
| `--admin-danger` | `#A8321C` | Errors / destructive |
|
|
||||||
| `--admin-danger-soft` | `#F7E3DE` | Danger at 12% |
|
|
||||||
|
|
||||||
**Drop**: `--admin-warning: #aba278` (looks like dirt), the old `citrus` orange in the active state, the blue/purple stat icons. Replace with semantic use of the new palette.
|
|
||||||
|
|
||||||
## 4. IA decision
|
|
||||||
|
|
||||||
Sidebar groups, top to bottom, with section dividers:
|
|
||||||
|
|
||||||
| Group | Items |
|
|
||||||
|---|---|
|
|
||||||
| **Workspace** | Dashboard · Command Center (platform_admin only) |
|
|
||||||
| **Operations** | Orders · Stops & Routes · Products · Driver Pickup · Shipping |
|
|
||||||
| **Communications** | Harvest Reach (campaigns, templates, contacts, segments, logs) |
|
|
||||||
| **Growth** | Wholesale · Import Center · AI Intelligence |
|
|
||||||
| **Tracking** | Time Tracking · Water Log · Route Trace |
|
|
||||||
| **Insights** | Reports · Tax Dashboard |
|
|
||||||
| **Settings** | General · Brand · Billing · Users & Permissions · Integrations · Payments · Shipping · Add-ons |
|
|
||||||
|
|
||||||
The old "Workers & PINs" sidebar item is a tab on Time Tracking now. Settings is one click → secondary nav inside settings (tabs at the top of the page).
|
|
||||||
|
|
||||||
**Cmd+K command palette** for global search across:
|
|
||||||
- Pages (jumps to any admin route)
|
|
||||||
- Recent orders / products / stops / customers (live search)
|
|
||||||
- Quick actions ("Create order", "Add product", "Send blast")
|
|
||||||
|
|
||||||
## 5. Component patterns (consistency pass)
|
|
||||||
|
|
||||||
| Pattern | Owner | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| `PageHeader` (eyebrow + title + subtitle + actions) | `src/components/admin/design-system/PageHeader.tsx` | exists, audit usage |
|
|
||||||
| `EmptyState` (icon + title + body + CTA) | same dir, currently `EmptyState.tsx` exists for shared, admin needs its own | add `.ha-empty` |
|
|
||||||
| `LoadingState` (skeleton + label) | `.ha-skeleton` exists in CSS, no component | wrap |
|
|
||||||
| `ConfirmDialog` (destructive action) | `AdminDeleteConfirm.tsx` exists | audit usage |
|
|
||||||
| `StatusPill` (success/warn/danger/info/neutral) | `AdminBadge.tsx` exists, variants are off | tighten variants |
|
|
||||||
| `KPIStat` (label + value + trend) | inline in `DashboardClient` | extract to component |
|
|
||||||
| `CommandPalette` (Cmd+K) | **new** | new component |
|
|
||||||
| `SideNavGroup` (label + items) | **new** | replaces flat NAV_ITEMS |
|
|
||||||
| `DataTable` (sortable, filterable, paginated) | `shared/DataTable.tsx` exists | tighten + use everywhere |
|
|
||||||
|
|
||||||
## 6. Phased execution (yolo, one day)
|
|
||||||
|
|
||||||
**Phase 1 — Foundation (commit per file)**
|
|
||||||
- Update `src/styles/admin-design-system.css` color tokens
|
|
||||||
- New `SideNavGroup` component
|
|
||||||
- Replace `AdminSidebar` flat list with grouped nav
|
|
||||||
- New `CommandPalette` component (mounted in admin layout)
|
|
||||||
- Refactor `DashboardClient` to unified command center (drop 4 tabs, single column feed)
|
|
||||||
|
|
||||||
**Phase 2 — Pattern extraction (commit per file)**
|
|
||||||
- Extract `KPIStat` from dashboard
|
|
||||||
- Refine `EmptyState` admin variant
|
|
||||||
- `LoadingState` wrapper
|
|
||||||
- `StatusPill` tighten variants
|
|
||||||
|
|
||||||
**Phase 3 — Apply to highest-traffic pages**
|
|
||||||
- `/admin/orders` + detail
|
|
||||||
- `/admin/products` + new + edit
|
|
||||||
- `/admin/stops` calendar
|
|
||||||
- `/admin/communications` composer
|
|
||||||
- `/admin/settings/*` tabs
|
|
||||||
|
|
||||||
**Phase 4 — Frontend polish**
|
|
||||||
- Public page spot-check: hero spacing, section rhythm
|
|
||||||
- Verify TypeScript + build green
|
|
||||||
|
|
||||||
## 7. Risk & revert
|
|
||||||
|
|
||||||
- Working on branch `design/ui-revamp-2026-06` (already created off `main`).
|
|
||||||
- **Every milestone is its own commit.** `git reset --hard <sha>` reverts to that point.
|
|
||||||
- **One-click full revert:** `git checkout main && git branch -D design/ui-revamp-2026-06` — but only after a failed `git push`.
|
|
||||||
- Type-check (`npx tsc --noEmit`) + `npm run build` are the gate at the end of each phase. Build green = milestone accepted.
|
|
||||||
- If something breaks the admin layout at runtime, `git revert HEAD` unwinds the most recent commit without losing history.
|
|
||||||
- **Do not delete or rename files in this branch.** Only add, only modify. Easier to revert.
|
|
||||||
|
|
||||||
## 8. Out of scope (yolo tomorrow, not now)
|
|
||||||
|
|
||||||
- Wholesale portal pages (`/wholesale/*`)
|
|
||||||
- Water-log standalone pages
|
|
||||||
- Public marketing pages (pricing, blog, changelog) — polish later
|
|
||||||
- Wholesale auth migration (separate task per MEMORY.md)
|
|
||||||
- Migrating remaining legacy `supabase.from()` calls (separate task per MEMORY.md)
|
|
||||||
|
|
||||||
## 9. What "done" looks like
|
|
||||||
|
|
||||||
- [ ] Color tokens unified, no muddy mix
|
|
||||||
- [ ] Sidebar has 6 visible groups with section labels
|
|
||||||
- [ ] Cmd+K opens a command palette that can navigate to any admin page
|
|
||||||
- [ ] Dashboard is a single feed, no 4-tab structure
|
|
||||||
- [ ] Top 6 admin pages use the new PageHeader / StatusPill / EmptyState consistently
|
|
||||||
- [ ] `npx tsc --noEmit` clean
|
|
||||||
- [ ] `npm run build` clean
|
|
||||||
- [ ] Dev server boots, `/admin` renders, sidebar works, Cmd+K opens
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
# Water Log
|
|
||||||
|
|
||||||
A standalone irrigation / water-usage tracker for ditch riders, water
|
|
||||||
admins, and farm operators. Tracks flow measurements against physical
|
|
||||||
headgates, attributes them to named water users, and rolls them up for
|
|
||||||
reporting.
|
|
||||||
|
|
||||||
The module is **PIN-based** and lives entirely outside the platform
|
|
||||||
admin auth: an irrigator with a 4-digit PIN can submit entries from a
|
|
||||||
phone in the field without an account on the platform. Site admins
|
|
||||||
manage headgates, users, and settings from `/admin/water-log`.
|
|
||||||
|
|
||||||
## When to use this
|
|
||||||
|
|
||||||
- You run irrigation ditches / headgates and need to track daily
|
|
||||||
flow with named operators.
|
|
||||||
- You need a mobile-friendly entry surface that works in low
|
|
||||||
connectivity (no login, just a PIN).
|
|
||||||
- You want a per-brand, brand-scoped record of who recorded what,
|
|
||||||
with audit trail + CSV export for water-rights reporting.
|
|
||||||
|
|
||||||
## When NOT to use this
|
|
||||||
|
|
||||||
- For sensor/IoT integrations, use the time-series / `Square Sync`
|
|
||||||
flow rather than this module — entries here are hand-typed.
|
|
||||||
- For multi-tenant water-rights billing, use the wholesale deposit
|
|
||||||
flow; this module is a measurement ledger, not a billing system.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture at a glance
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────────────┐ ┌─────────────────────────┐
|
|
||||||
│ /water (PIN form) │ │ /water/admin/login │
|
|
||||||
│ irrigator → submit │ │ (water_admin PIN) │
|
|
||||||
│ → wl_session cookie │ │ → wl_admin_session │
|
|
||||||
└──────────┬─────────────┘ └──────────┬──────────────┘
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────┘
|
|
||||||
▼ ▼
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ Postgres (RLS) — brand scoped │
|
|
||||||
│ water_headgates, water_irrigators, │
|
|
||||||
│ water_log_entries, water_sessions, │
|
|
||||||
│ water_admin_sessions, water_ │
|
|
||||||
│ audit_log, water_alert_log, │
|
|
||||||
│ water_admin_settings │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
▲
|
|
||||||
│
|
|
||||||
┌─────────────┴──────────────┐
|
|
||||||
│ /admin/water-log │
|
|
||||||
│ Site-admin CRUD + exports │
|
|
||||||
└────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Two separate PIN surfaces:**
|
|
||||||
|
|
||||||
| Surface | Cookie | TTL | Purpose |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `/water` (irrigator) | `wl_session` | 8 h | Submit flow entries |
|
|
||||||
| `/water/admin` (admin) | `wl_admin_session` | configurable (1–168 h) | Manage headgates/users, view all entries |
|
|
||||||
|
|
||||||
Both cookies are `httpOnly`, `sameSite=lax`, and `secure` in production.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data model
|
|
||||||
|
|
||||||
### `water_headgates`
|
|
||||||
Physical gates a measurement is tied to.
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | uuid PK | |
|
|
||||||
| `brand_id` | uuid FK → brands | brand scope (RLS) |
|
|
||||||
| `name` | text | e.g. "Upper Ditch Headgate" |
|
|
||||||
| `headgate_token` | text UNIQUE | opaque token used in the QR code |
|
|
||||||
| `status` | text | `open` / `closed` / `maintenance` |
|
|
||||||
| `unit` | text | default measurement unit (CFS, GPM, AF, …) |
|
|
||||||
| `max_flow_gpm` | numeric | optional marker |
|
|
||||||
| `high_threshold` | numeric | alert when reading > this |
|
|
||||||
| `low_threshold` | numeric | alert when reading < this |
|
|
||||||
| `notes` | text | |
|
|
||||||
| `active` | bool | soft-delete |
|
|
||||||
| `last_used_at` | timestamptz | updated on each entry |
|
|
||||||
| `created_at` | timestamptz | |
|
|
||||||
|
|
||||||
### `water_irrigators`
|
|
||||||
Field workers with PIN access.
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | uuid PK | |
|
|
||||||
| `brand_id` | uuid FK → brands | |
|
|
||||||
| `name` | text | |
|
|
||||||
| `pin_hash` | text | scrypt `$N$r$p$salt$hash` — never plain |
|
|
||||||
| `role` | text | `irrigator` (submit only) or `water_admin` (manage) |
|
|
||||||
| `language_preference` | text | `en` / `es` |
|
|
||||||
| `phone` | text | optional |
|
|
||||||
| `notes` | text | |
|
|
||||||
| `active` | bool | soft-delete |
|
|
||||||
| `last_used_at` | timestamptz | |
|
|
||||||
| `created_at` | timestamptz | |
|
|
||||||
|
|
||||||
### `water_log_entries`
|
|
||||||
The actual readings.
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | uuid PK | |
|
|
||||||
| `brand_id` | uuid FK → brands | |
|
|
||||||
| `headgate_id` | uuid FK | |
|
|
||||||
| `irrigator_id` | uuid FK | who submitted |
|
|
||||||
| `measurement` | numeric | raw value in `unit` |
|
|
||||||
| `unit` | text | CFS, GPM, AF/Day, etc. |
|
|
||||||
| `method` | text | `manual` / `meter` / `estimate` / `qr` |
|
|
||||||
| `total_gallons` | numeric | auto-computed when CFS × duration is known |
|
|
||||||
| `notes` | text | |
|
|
||||||
| `submitted_via` | text | `field` / `admin` / `qr` |
|
|
||||||
| `photo_url` | text | optional |
|
|
||||||
| `latitude` / `longitude` | double precision | optional GPS |
|
|
||||||
| `logged_date` | date | date-only mirror for fast grouping |
|
|
||||||
| `logged_at` | timestamptz | the actual time |
|
|
||||||
| `logged_by` | uuid FK → admin_users | set when a site admin enters data |
|
|
||||||
|
|
||||||
### Sessions
|
|
||||||
- `water_sessions` — short-lived (8 h) PIN sessions for irrigators.
|
|
||||||
- `water_admin_sessions` — admin sign-in sessions, TTL from
|
|
||||||
`water_admin_settings.session_duration_hours`.
|
|
||||||
|
|
||||||
### Audit + alerts
|
|
||||||
- `water_audit_log` — who changed what, when. Captures every
|
|
||||||
headgate/user/setting mutation with actor + JSON details.
|
|
||||||
- `water_alert_log` — high/low threshold breach history.
|
|
||||||
- `water_admin_settings` — per-brand admin PIN, permission flags,
|
|
||||||
alert config, session duration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security model
|
|
||||||
|
|
||||||
### PIN hashing
|
|
||||||
PINs are hashed with **scrypt** (Node built-in `crypto.scryptSync`)
|
|
||||||
at N=16384, r=8, p=1, 32-byte key, with a per-PIN random salt. The
|
|
||||||
hash is self-describing: `scrypt$N$r$p$salt_b64$hash_b64`. We avoid
|
|
||||||
adding a `bcrypt` or `argon2` dependency — `scrypt` is in the Node
|
|
||||||
core and matches the password module already used elsewhere.
|
|
||||||
|
|
||||||
### Brute-force hardening
|
|
||||||
- 4–8 digit PINs are low entropy, so we:
|
|
||||||
- Reject "weak" PINs at generation (`generatePin` skips 1111, 1234,
|
|
||||||
palindromes, monotonic sequences).
|
|
||||||
- Add a 200 ms delay on every failed `verifyPin` call to slow
|
|
||||||
online guessing.
|
|
||||||
- Use `timingSafeEqual` for the hash comparison.
|
|
||||||
- Sessions are short-lived (8 h for irrigators, configurable 1–168 h
|
|
||||||
for admins).
|
|
||||||
- The admin PIN can be regenerated from `/admin/water-log/settings` —
|
|
||||||
regenerating invalidates all existing admin sessions.
|
|
||||||
|
|
||||||
### Auth gates
|
|
||||||
Every server action enforces one of:
|
|
||||||
- `requireFieldSession()` — reads `wl_session` cookie, looks up
|
|
||||||
`water_sessions` row, verifies `expires_at`. Returns userId +
|
|
||||||
brandId.
|
|
||||||
- `requireWaterAdminPermission()` — calls `getAdminUser()` and checks
|
|
||||||
`can_manage_water_log` OR `role === "platform_admin"`.
|
|
||||||
- `requireWaterAdminSession()` — reads `wl_admin_session` cookie and
|
|
||||||
verifies the row.
|
|
||||||
|
|
||||||
There is **no implicit "logged in"** state. Each action that mutates
|
|
||||||
data explicitly checks its gate.
|
|
||||||
|
|
||||||
### Data isolation
|
|
||||||
- All tables have RLS policies (`withBrand()` helper sets a
|
|
||||||
per-request GUC; the policy reads it).
|
|
||||||
- `globalThis.__TUXEDO_BRAND_ID__` is a one-time Tuxedo-brand hint for
|
|
||||||
cold-start paths; the active `getAdminUser().brand_id` always wins
|
|
||||||
on later requests.
|
|
||||||
- No Supabase REST, no service-role keys — all access is direct
|
|
||||||
through Drizzle over a single `pg` Pool.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Day-to-day usage
|
|
||||||
|
|
||||||
### Add a headgate
|
|
||||||
1. `/admin/water-log` → scroll to **Headgates** → **+ Add Headgate**.
|
|
||||||
2. Enter a unique name (e.g. "North Field Gate 1"), default unit,
|
|
||||||
optional thresholds.
|
|
||||||
3. The new headgate gets a printable QR code. Print it and stick it
|
|
||||||
on the gate.
|
|
||||||
|
|
||||||
### Add a water user
|
|
||||||
1. **Water Users** → **+ Add User**.
|
|
||||||
2. Enter name, choose role (Admin or Irrigator), pick language.
|
|
||||||
3. A 4-digit PIN is generated. **Write it down now** — it is shown
|
|
||||||
once and never recoverable.
|
|
||||||
4. Hand the PIN to the worker. They sign in at `/water` with just
|
|
||||||
that.
|
|
||||||
|
|
||||||
### Submit a flow entry (irrigator)
|
|
||||||
1. Open `/water` on a phone → enter 4-digit PIN.
|
|
||||||
2. Pick the headgate (or scan the QR code — it deep-links to that
|
|
||||||
gate).
|
|
||||||
3. Enter the measurement, duration, method, optional notes/photo.
|
|
||||||
4. Tap **Submit**. Total gallons is auto-computed when CFS × duration
|
|
||||||
is known.
|
|
||||||
5. If the reading crosses a high/low threshold, an alert row is
|
|
||||||
written to `water_alert_log` and (if enabled) an SMS is dispatched
|
|
||||||
to the configured phone.
|
|
||||||
|
|
||||||
### Edit or delete an entry (admin)
|
|
||||||
- From the **Recent Entries** table, click any row.
|
|
||||||
- Edit form is gated by `canEditEntries` / `canDeleteEntries` in
|
|
||||||
`water_admin_settings`. Defaults: edit ✅, delete ✅.
|
|
||||||
|
|
||||||
### Reset a PIN
|
|
||||||
- **Water Users** row → **Reset PIN** → new PIN is generated and
|
|
||||||
shown once.
|
|
||||||
|
|
||||||
### Export CSV
|
|
||||||
- **Recent Entries** → **Export CSV** button. Or hit
|
|
||||||
`GET /api/water-logs/export?format=csv` (auth-gated, requires
|
|
||||||
`can_manage_water_log`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API surface
|
|
||||||
|
|
||||||
| Route | Method | Auth | Purpose |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `/api/water-admin-auth` | POST | none (PIN-protected) | Exchange admin PIN for `wl_admin_session` cookie |
|
|
||||||
| `/api/water-logs/export` | GET | admin | Stream entries as JSON or CSV |
|
|
||||||
|
|
||||||
`POST /api/water-admin-auth` body:
|
|
||||||
```json
|
|
||||||
{ "brandId": "64294306-5f42-463d-a5e8-2ad6c81a96de", "pin": "1234" }
|
|
||||||
```
|
|
||||||
|
|
||||||
On success, sets the `wl_admin_session` cookie. On failure, returns
|
|
||||||
a generic error (we don't leak whether the brand has a PIN configured
|
|
||||||
vs. whether the PIN is wrong).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Unit (Vitest)
|
|
||||||
```bash
|
|
||||||
npm test -- tests/unit/water-log
|
|
||||||
```
|
|
||||||
Covers:
|
|
||||||
- PIN format validation + weak-PIN detection
|
|
||||||
- scrypt round-trip + tampering
|
|
||||||
- Reporting utilities (CSV escaping, date filters, season detection)
|
|
||||||
- Display age helpers
|
|
||||||
|
|
||||||
### E2E (Playwright)
|
|
||||||
```bash
|
|
||||||
npx playwright test water-log
|
|
||||||
```
|
|
||||||
Covers:
|
|
||||||
- `/water` and `/water/admin/login` render with PIN form
|
|
||||||
- PIN input strips non-digits and caps at 4 chars
|
|
||||||
- Wrong PIN does not navigate
|
|
||||||
- `/admin/water-log/*` redirects unauthenticated users
|
|
||||||
- `/api/water-logs/export` and `/api/water-admin-auth` are auth-gated
|
|
||||||
|
|
||||||
For the full DB-backed workflow (add headgate → add user → submit →
|
|
||||||
export), set `WATER_LOG_E2E_DB=1` and run the same command against a
|
|
||||||
test database.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment variables
|
|
||||||
|
|
||||||
No Water Log–specific env vars are required. The module uses the
|
|
||||||
existing `DATABASE_URL` and (optionally) the MinIO bucket
|
|
||||||
`MINIO_BUCKET_WATER_LOGS` for photo uploads.
|
|
||||||
|
|
||||||
If you want high/low threshold SMS alerts, configure
|
|
||||||
`/admin/water-log/settings` with a phone number — SMS dispatch uses
|
|
||||||
the brand's existing Twilio config (same as Harvest Reach).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration history
|
|
||||||
|
|
||||||
- `0001_init.sql` — initial `water_*` tables + RLS policies.
|
|
||||||
- `0090_water_log_completion.sql` — adds `headgate_token`, threshold
|
|
||||||
fields, `role` on irrigators, `method` + `logged_date` on entries,
|
|
||||||
plus the `water_admin_*`, `water_audit_log`, and `water_alert_log`
|
|
||||||
tables.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Admin function checklist
|
|
||||||
|
|
||||||
Manual regression pass after changes:
|
|
||||||
|
|
||||||
### Field side
|
|
||||||
- [ ] `/water` PIN screen loads, accepts 4 digits, rejects letters
|
|
||||||
- [ ] Wrong PIN shows an error, does not redirect
|
|
||||||
- [ ] Correct PIN shows the entry form with the user's headgates
|
|
||||||
- [ ] Submitting an entry creates a row, shows confirmation, returns
|
|
||||||
to the form
|
|
||||||
- [ ] QR-code link (`/water?h=TOKEN`) pre-selects the headgate
|
|
||||||
- [ ] High/low threshold entries write a `water_alert_log` row
|
|
||||||
|
|
||||||
### Admin side
|
|
||||||
- [ ] `/admin/water-log` shows headgates, users, recent entries
|
|
||||||
- [ ] Add headgate → appears in list + QR can be generated
|
|
||||||
- [ ] Edit headgate thresholds → reflected in the field form
|
|
||||||
- [ ] Add user → PIN shown once, user appears in list
|
|
||||||
- [ ] Reset PIN → new PIN shown, old sessions invalidated
|
|
||||||
- [ ] Edit entry → measurement, notes, method all updatable
|
|
||||||
- [ ] Delete entry → row removed (or soft-flagged per audit policy)
|
|
||||||
- [ ] Filter by date range / headgate / user / method works
|
|
||||||
- [ ] CSV export downloads valid CSV
|
|
||||||
- [ ] Audit log shows the actor for every change
|
|
||||||
|
|
||||||
### Settings + portal
|
|
||||||
- [ ] `/admin/water-log/settings` shows current config
|
|
||||||
- [ ] Toggling "Enable Admin Portal" blocks `/water/admin/login`
|
|
||||||
- [ ] Regenerating admin PIN signs out all current admin sessions
|
|
||||||
- [ ] Session duration slider clamps to 1–168 hours
|
|
||||||
|
|
||||||
### Edge cases
|
|
||||||
- [ ] Zero data: empty states render, no crashes
|
|
||||||
- [ ] Invalid PIN: form rejects, error is friendly
|
|
||||||
- [ ] Duplicate headgate name: server rejects, UI shows error
|
|
||||||
- [ ] Very large measurement (1e9): renders, doesn't blow up float
|
|
||||||
- [ ] 1,000+ entries: list paginates, filters stay responsive
|
|
||||||
- [ ] Concurrent submissions: both rows present, no lost writes
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
});
|
|
||||||
@@ -12,10 +12,6 @@ const eslintConfig = defineConfig([
|
|||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
// Ignore legacy .js scripts that use CommonJS
|
|
||||||
"scripts/**",
|
|
||||||
"supabase/**",
|
|
||||||
"fix-agents.js",
|
|
||||||
]),
|
]),
|
||||||
// Allow setState in useEffect for PWA prompts and client-side state initialization
|
// Allow setState in useEffect for PWA prompts and client-side state initialization
|
||||||
{
|
{
|
||||||
@@ -24,14 +20,6 @@ const eslintConfig = defineConfig([
|
|||||||
"react-hooks/set-state-in-effect": "off",
|
"react-hooks/set-state-in-effect": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Relax some rules for legacy code
|
|
||||||
{
|
|
||||||
files: ["db/**", "scripts/**", "supabase/**"],
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/no-require-imports": "off",
|
|
||||||
"@typescript-eslint/no-explicit-any": "warn",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { getSessionCookie } from "better-auth/cookies";
|
||||||
|
|
||||||
|
const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const response = NextResponse.next({ request });
|
||||||
|
|
||||||
|
// ── Dev session bypass (enabled in all envs for demo) ──────────────
|
||||||
|
// Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
|
||||||
|
const devSession = request.cookies.get("dev_session")?.value;
|
||||||
|
const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee";
|
||||||
|
|
||||||
|
// Better Auth sets cookie named "rc_session_token" by default (with cookiePrefix: "rc")
|
||||||
|
const sessionCookie = getSessionCookie(request);
|
||||||
|
const hasSession = Boolean(sessionCookie);
|
||||||
|
|
||||||
|
let authed = false;
|
||||||
|
if (isDevMode) {
|
||||||
|
authed = true;
|
||||||
|
} else if (hasSession) {
|
||||||
|
authed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = request.nextUrl.pathname.startsWith("/admin");
|
||||||
|
const isLogin = request.nextUrl.pathname === "/login";
|
||||||
|
|
||||||
|
if (isAdmin && !authed) {
|
||||||
|
// Auto-login for demo: no auth cookie present
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = "/admin";
|
||||||
|
url.searchParams.set("demo", "1");
|
||||||
|
const response = NextResponse.redirect(url);
|
||||||
|
response.cookies.set("dev_session", "platform_admin", {
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "strict",
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLogin && authed) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = "/admin";
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/admin/:path*",
|
||||||
|
"/admin",
|
||||||
|
"/login",
|
||||||
|
],
|
||||||
|
};
|
||||||
+29
-21
@@ -1,21 +1,6 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// Enable standalone output for Docker/PM2 deployment
|
|
||||||
output: "standalone",
|
|
||||||
|
|
||||||
// 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. We resolve to an absolute path to avoid the warning in
|
|
||||||
// Next.js 16 which prefers absolute paths here.
|
|
||||||
outputFileTracingRoot: __dirname,
|
|
||||||
|
|
||||||
// Enable strict mode
|
// Enable strict mode
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
@@ -34,6 +19,24 @@ const nextConfig: NextConfig = {
|
|||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "picsum.photos",
|
hostname: "picsum.photos",
|
||||||
},
|
},
|
||||||
|
// Local self-hosted MinIO (replaces Supabase Storage)
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
hostname: "localhost",
|
||||||
|
port: "9000",
|
||||||
|
pathname: "/**",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: "9000",
|
||||||
|
pathname: "/**",
|
||||||
|
},
|
||||||
|
// Production MinIO behind route.crispygoat.com
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "storage.route.crispygoat.com",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
formats: ["image/avif", "image/webp"],
|
formats: ["image/avif", "image/webp"],
|
||||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||||
@@ -103,8 +106,18 @@ const nextConfig: NextConfig = {
|
|||||||
|
|
||||||
// Rewrites for API proxy
|
// Rewrites for API proxy
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
|
// Storage proxy: /storage/* -> MinIO at the same path
|
||||||
|
// Lets brand assets and product images use portable relative URLs
|
||||||
|
// (e.g. /storage/brand-logos/<id>/logo.png) in the DB, with Next.js
|
||||||
|
// proxying to whichever MinIO endpoint is configured for the environment.
|
||||||
|
// Avoids the next/image "upstream resolved to private ip" block on
|
||||||
|
// localhost MinIO by keeping the upstream fetch on the server side.
|
||||||
|
const storageBase = process.env.STORAGE_PUBLIC_URL || "http://localhost:9000";
|
||||||
return [
|
return [
|
||||||
// Add any necessary rewrites here
|
{
|
||||||
|
source: "/storage/:path*",
|
||||||
|
destination: `${storageBase}/:path*`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -112,11 +125,6 @@ const nextConfig: NextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
// Enable optimizePackageImports for better bundle size
|
// Enable optimizePackageImports for better bundle size
|
||||||
optimizePackageImports: ["lucide-react", "@radix-ui/react-icons", "framer-motion"],
|
optimizePackageImports: ["lucide-react", "@radix-ui/react-icons", "framer-motion"],
|
||||||
// Enable React's <ViewTransition> and Next.js' automatic route
|
|
||||||
// transitions. Combined with the smooth-transition wrappers around
|
|
||||||
// page content (see src/components/transitions), navigation feels
|
|
||||||
// like a single continuous app rather than a sequence of page loads.
|
|
||||||
viewTransition: true,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Compiler options
|
// Compiler options
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 352 KiB |
+10
-31
@@ -1,50 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "route-commerce-platform",
|
"name": "route-commerce-platform",
|
||||||
"version": "2.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000",
|
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0",
|
||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"lint:fix": "eslint --fix",
|
"lint:fix": "eslint --fix",
|
||||||
"migrate": "node scripts/migrate.js",
|
"migrate": "node supabase/push-migrations.js",
|
||||||
"migrate:one": "node scripts/migrate.js",
|
"migrate:one": "node supabase/push-migrations.js",
|
||||||
"db:migrate": "node scripts/migrate.js",
|
|
||||||
"db:seed": "tsx db/seed.ts",
|
|
||||||
"db:seed:tour": "node scripts/seed-tuxedo-2026.js",
|
|
||||||
"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",
|
||||||
"@aws-sdk/client-s3": "^3.1064.0",
|
"@aws-sdk/client-s3": "^3.1062.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1064.0",
|
"@aws-sdk/s3-request-presigner": "^3.1062.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",
|
||||||
"@neondatabase/auth": "^0.4.2-beta",
|
|
||||||
"@neondatabase/serverless": "^1.1.0",
|
|
||||||
"@sentry/nextjs": "^10.55.0",
|
"@sentry/nextjs": "^10.55.0",
|
||||||
"@stripe/react-stripe-js": "^6.6.0",
|
|
||||||
"@stripe/stripe-js": "^9.7.0",
|
|
||||||
"@supabase/ssr": "^0.10.2",
|
|
||||||
"@supabase/supabase-js": "^2.105.3",
|
|
||||||
"@upstash/ratelimit": "^2.0.8",
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
"@upstash/redis": "^1.38.0",
|
"@upstash/redis": "^1.38.0",
|
||||||
"drizzle-orm": "^0.36.4",
|
"better-auth": "^1.6.14",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
|
"kysely": "^0.29.2",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
"next-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",
|
||||||
@@ -70,19 +55,13 @@
|
|||||||
"@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",
|
||||||
"tsx": "^4.22.4",
|
"typescript": "^5"
|
||||||
"typescript": "^5",
|
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
|
||||||
"vitest": "^2.1.9"
|
|
||||||
},
|
},
|
||||||
"overrides": "{}"
|
"overrides": "{}"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-15
@@ -1,34 +1,24 @@
|
|||||||
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,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
// Playwright should only run E2E specs — vitest owns everything under
|
|
||||||
// tests/unit/. The glob below matches *.spec.ts at the top of tests/ and
|
|
||||||
// tests/e2e/ and tests/login/ but skips the .test.ts files (vitest).
|
|
||||||
testMatch: /(tests\/(smoke|e2e|login)\/.*|\/[^/]+\.spec\.ts$)/,
|
|
||||||
reporter: "list",
|
reporter: "list",
|
||||||
use: {
|
use: {
|
||||||
baseURL: LOCAL_BASE,
|
baseURL: "https://route-commerce-platform.vercel.app",
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
|
||||||
name: "local",
|
|
||||||
use: { ...devices["Desktop Chrome"], baseURL: LOCAL_BASE },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "production",
|
name: "production",
|
||||||
// `PLAYWRIGHT_PROD=1 npx playwright test` to run against the live site.
|
use: {
|
||||||
testMatch: /.*\.prod\.spec\.ts$/,
|
...devices["Desktop Chrome"],
|
||||||
use: { ...devices["Desktop Chrome"], baseURL: PROD_BASE },
|
baseURL: "https://route-commerce-platform.vercel.app",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 450 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 352 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 352 KiB |
@@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cleanup script: remove duplicate stops from the Tuxedo Corn 2026 tour.
|
|
||||||
* Keeps the row with the latest created_at per (brand_id, date, city, state, location, time).
|
|
||||||
* Run: DATABASE_URL="postgresql://..." npx tsx scripts/cleanup-duplicate-stops.ts
|
|
||||||
*/
|
|
||||||
import { Pool } from "pg";
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
// Find duplicate groups (same brand_id, date, city, state, location, time)
|
|
||||||
const dupes = await client.query(`
|
|
||||||
SELECT brand_id::text, date, city, state, location, time, count(*) as cnt, min(created_at::text) as oldest, max(created_at::text) as newest
|
|
||||||
FROM stops
|
|
||||||
WHERE deleted_at IS NULL
|
|
||||||
GROUP BY brand_id, date, city, state, location, time
|
|
||||||
HAVING count(*) > 1
|
|
||||||
ORDER BY date, city
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log(`Found ${dupes.rowCount} duplicate groups`);
|
|
||||||
if (dupes.rowCount === 0) {
|
|
||||||
await client.query("COMMIT");
|
|
||||||
console.log("No duplicates found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalDeleted = 0;
|
|
||||||
for (const row of dupes.rows) {
|
|
||||||
// Keep the row with the latest created_at, delete the rest
|
|
||||||
const del = await client.query(`
|
|
||||||
DELETE FROM stops
|
|
||||||
WHERE brand_id = $1
|
|
||||||
AND date = $2
|
|
||||||
AND city = $3
|
|
||||||
AND state = $4
|
|
||||||
AND location = $5
|
|
||||||
AND time IS NOT DISTINCT FROM $6
|
|
||||||
AND deleted_at IS NULL
|
|
||||||
AND created_at < (
|
|
||||||
SELECT max(created_at) FROM stops s2
|
|
||||||
WHERE s2.brand_id = stops.brand_id
|
|
||||||
AND s2.date = stops.date
|
|
||||||
AND s2.city = stops.city
|
|
||||||
AND s2.state = stops.state
|
|
||||||
AND s2.location = stops.location
|
|
||||||
AND (s2.time IS NOT DISTINCT FROM stops.time)
|
|
||||||
AND s2.deleted_at IS NULL
|
|
||||||
)
|
|
||||||
`, [row.brand_id, row.date, row.city, row.state, row.location, row.time]);
|
|
||||||
totalDeleted += del.rowCount ?? 0;
|
|
||||||
console.log(` Deleted ${del.rowCount} duplicate(s) for ${row.city}, ${row.state} on ${row.date}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.query("COMMIT");
|
|
||||||
console.log(`\nDone. Total duplicates removed: ${totalDeleted}`);
|
|
||||||
} catch (e) {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Create an admin user in Neon Auth via direct HTTP call.
|
|
||||||
* Run: npx tsx scripts/create-admin-user.ts [email] [password] [name]
|
|
||||||
*
|
|
||||||
* Makes a direct HTTP request to the Neon Auth API so we don't need
|
|
||||||
* a Next.js request context, then updates email_verified in the DB.
|
|
||||||
*/
|
|
||||||
import { config } from "dotenv";
|
|
||||||
config({ path: ".env.local" });
|
|
||||||
import pg from "pg";
|
|
||||||
|
|
||||||
const { Pool } = pg;
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const email = process.argv[2] ?? "admin@example.com";
|
|
||||||
const password = process.argv[3] ?? "Admin1234!";
|
|
||||||
const name = process.argv[4] ?? "Admin";
|
|
||||||
|
|
||||||
const baseUrl = process.env.NEON_AUTH_BASE_URL!;
|
|
||||||
|
|
||||||
console.log(`Creating user: ${email}`);
|
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/sign-up/email`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Origin": "http://localhost:4000",
|
|
||||||
"x-neon-auth-proxy": "node",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email, password, name, callbackURL: "/admin" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
console.log(`Sign-up status: ${res.status}`);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
console.error("Failed to create user:", JSON.stringify(data));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = data.user?.id;
|
|
||||||
console.log("User created:", data.user?.email, "| ID:", userId);
|
|
||||||
|
|
||||||
// Mark email verified in DB so they can log in immediately
|
|
||||||
if (userId) {
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"UPDATE neon_auth.user SET email_verified = true WHERE id = $1",
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
console.log("Email verified in DB.");
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nLog in at http://localhost:4000/login with ${email} / ${password}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/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");
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
// dotenv load temporarily disabled so shell-provided DATABASE_URL wins for this run
|
|
||||||
// import { config } from "dotenv";
|
|
||||||
// config({ path: ".env.local" });
|
|
||||||
import { Pool } from "pg";
|
|
||||||
import ExcelJS from "exceljs";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
|
||||||
const YEAR = 2026;
|
|
||||||
|
|
||||||
const MONTH_MAP: Record<string, string> = {
|
|
||||||
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
|
||||||
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ParsedStop {
|
|
||||||
week: string;
|
|
||||||
region: string;
|
|
||||||
date: string;
|
|
||||||
day: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
location: string;
|
|
||||||
time: string;
|
|
||||||
timeRange: string;
|
|
||||||
truck: string;
|
|
||||||
statusText: string;
|
|
||||||
notes: string;
|
|
||||||
address: string | null;
|
|
||||||
phone: string | null;
|
|
||||||
contact: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseExcelDate(s: unknown): string | null {
|
|
||||||
if (!s) return null;
|
|
||||||
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
|
|
||||||
if (!m) return null;
|
|
||||||
const mm = MONTH_MAP[m[1]];
|
|
||||||
if (!mm) return null;
|
|
||||||
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTimeRange(s: unknown): string {
|
|
||||||
if (!s) return "";
|
|
||||||
let c = String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
|
|
||||||
const m = /^(\d{1,2}:\d{2}\s*[AP]M)/i.exec(c);
|
|
||||||
// Prefer full range for display (e.g. "10:00 AM – 1:00 PM"); fall back to start only
|
|
||||||
return c || (m ? m[1].toUpperCase().replace(" ", " ") : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitCityState(s: unknown): [string, string] {
|
|
||||||
if (!s) return ["", ""];
|
|
||||||
const parts = String(s).split(",").map((p) => p.trim());
|
|
||||||
if (parts.length === 1) return [parts[0], ""];
|
|
||||||
return [parts[0], parts[1]];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWeekHeader(cells: string[]): boolean {
|
|
||||||
return /^Wk\s/i.test(cells[0] || "") && (cells[3] || "").trim() === "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOffRow(cells: string[]): boolean {
|
|
||||||
const d = (cells[3] || "").trim();
|
|
||||||
return /OFF|Cross-?Dock/i.test(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDataRow(cells: string[]): boolean {
|
|
||||||
const day = (cells[3] || "").trim();
|
|
||||||
const city = (cells[4] || "").trim();
|
|
||||||
return !!(day && city && city.includes(","));
|
|
||||||
}
|
|
||||||
|
|
||||||
function slugify(s: string): string {
|
|
||||||
let out = (s || "").toLowerCase();
|
|
||||||
out = out.replace(/[^a-z0-9]+/g, "-");
|
|
||||||
return out.replace(/^-+|-+$/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStops(xlsxPath: string): Promise<{ stops: ParsedStop[]; skipped: Record<string, number>; dirCount: number; locations: ParsedLocation[] }> {
|
|
||||||
const wb = new ExcelJS.Workbook();
|
|
||||||
await wb.xlsx.readFile(xlsxPath);
|
|
||||||
|
|
||||||
const schedule = wb.getWorksheet("Full Schedule");
|
|
||||||
const directory = wb.getWorksheet("Stop Directory");
|
|
||||||
if (!schedule) throw new Error("Missing 'Full Schedule' sheet");
|
|
||||||
|
|
||||||
// Build directory lookup for address enrichment: "T1|host lower" -> info
|
|
||||||
const dirMap = new Map<string, { address: string | null; phone: string | null; contact: string | null; state: string }>();
|
|
||||||
if (directory) {
|
|
||||||
for (let r = 2; r <= directory.rowCount; r++) {
|
|
||||||
const row = directory.getRow(r);
|
|
||||||
const truck = String(row.getCell(1).value || "").trim();
|
|
||||||
const host = String(row.getCell(4).value || "").trim().toLowerCase();
|
|
||||||
if (!truck || !host) continue;
|
|
||||||
dirMap.set(`${truck}|${host}`, {
|
|
||||||
address: String(row.getCell(5).value || "").trim() || null,
|
|
||||||
phone: String(row.getCell(6).value || "").trim() || null,
|
|
||||||
contact: String(row.getCell(7).value || "").trim() || null,
|
|
||||||
state: String(row.getCell(3).value || "").trim(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stops: ParsedStop[] = [];
|
|
||||||
const skipped: Record<string, number> = { weekHeader: 0, off: 0, invalid: 0, noDate: 0 };
|
|
||||||
|
|
||||||
for (let r = 4; r <= schedule.rowCount; r++) {
|
|
||||||
const row = schedule.getRow(r);
|
|
||||||
const cells = Array.from({ length: 10 }, (_, i) => String(row.getCell(i + 1).value ?? "").trim());
|
|
||||||
|
|
||||||
if (isWeekHeader(cells)) {
|
|
||||||
skipped.weekHeader++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isOffRow(cells)) {
|
|
||||||
skipped.off++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!isDataRow(cells)) {
|
|
||||||
skipped.invalid++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [wk, region, dateText, day, cityState, host, time, truck, status, notes] = cells;
|
|
||||||
const dateIso = parseExcelDate(dateText);
|
|
||||||
if (!dateIso) {
|
|
||||||
skipped.noDate++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const [city, state] = splitCityState(cityState);
|
|
||||||
if (!city) {
|
|
||||||
skipped.invalid++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = `${truck}|${host.toLowerCase()}`;
|
|
||||||
const d = dirMap.get(key);
|
|
||||||
|
|
||||||
stops.push({
|
|
||||||
week: wk,
|
|
||||||
region,
|
|
||||||
date: dateIso,
|
|
||||||
day,
|
|
||||||
city,
|
|
||||||
state: state || (d?.state || ""),
|
|
||||||
location: host,
|
|
||||||
time: parseTimeRange(time),
|
|
||||||
timeRange: time,
|
|
||||||
truck,
|
|
||||||
statusText: status,
|
|
||||||
notes: notes || "",
|
|
||||||
address: d?.address || null,
|
|
||||||
phone: d?.phone || null,
|
|
||||||
contact: d?.contact || null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { stops, skipped, dirCount: dirMap.size, locations: loadLocations(directory) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ParsedLocation = {
|
|
||||||
name: string;
|
|
||||||
address: string | null;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
phone: string | null;
|
|
||||||
contactName: string | null;
|
|
||||||
notes: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function loadLocations(directory: ExcelJS.Worksheet | undefined): ParsedLocation[] {
|
|
||||||
if (!directory) return [];
|
|
||||||
const byKey = new Map<string, ParsedLocation>();
|
|
||||||
for (let r = 2; r <= directory.rowCount; r++) {
|
|
||||||
const row = directory.getRow(r);
|
|
||||||
const truck = String(row.getCell(1).value || "").trim();
|
|
||||||
const city = String(row.getCell(2).value || "").trim();
|
|
||||||
const state = String(row.getCell(3).value || "").trim();
|
|
||||||
const host = String(row.getCell(4).value || "").trim();
|
|
||||||
const address = String(row.getCell(5).value || "").trim() || null;
|
|
||||||
let phone = String(row.getCell(6).value || "").trim() || null;
|
|
||||||
const contact = String(row.getCell(7).value || "").trim() || null;
|
|
||||||
// notes column is 12
|
|
||||||
let notes = String(row.getCell(12).value || "").trim() || null;
|
|
||||||
|
|
||||||
if (!host || !city) continue;
|
|
||||||
|
|
||||||
// If phone field contains TBD email, move it to notes
|
|
||||||
if (phone && /@/.test(phone) && (!notes || notes.length < 10)) {
|
|
||||||
notes = phone;
|
|
||||||
phone = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = `${host.toLowerCase()}|${city.toLowerCase()}|${state.toLowerCase()}`;
|
|
||||||
if (!byKey.has(key)) {
|
|
||||||
byKey.set(key, {
|
|
||||||
name: host,
|
|
||||||
address,
|
|
||||||
city,
|
|
||||||
state,
|
|
||||||
phone,
|
|
||||||
contactName: contact || null,
|
|
||||||
notes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(byKey.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
function toLocationRpcRow(l: ParsedLocation) {
|
|
||||||
return {
|
|
||||||
name: l.name,
|
|
||||||
address: l.address,
|
|
||||||
city: l.city,
|
|
||||||
state: l.state,
|
|
||||||
zip: null,
|
|
||||||
phone: l.phone,
|
|
||||||
contact_name: l.contactName,
|
|
||||||
contact_email: null,
|
|
||||||
notes: l.notes,
|
|
||||||
active: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRpcRow(s: ParsedStop) {
|
|
||||||
// Date format matches the python seed script expectation for the RPC
|
|
||||||
const dateWithTz = `${s.date} 00:00:00+00`;
|
|
||||||
const combinedNotes = [
|
|
||||||
s.notes,
|
|
||||||
s.truck ? `Truck: ${s.truck}` : "",
|
|
||||||
s.statusText ? `Status: ${s.statusText}` : "",
|
|
||||||
s.phone ? `Phone: ${s.phone}` : "",
|
|
||||||
s.contact ? `Contact: ${s.contact}` : "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" | ");
|
|
||||||
|
|
||||||
return {
|
|
||||||
city: s.city,
|
|
||||||
state: s.state,
|
|
||||||
location: s.location,
|
|
||||||
date: dateWithTz,
|
|
||||||
time: s.time || s.timeRange || "",
|
|
||||||
address: s.address,
|
|
||||||
zip: null,
|
|
||||||
cutoff_time: null,
|
|
||||||
// active=true makes them visible on the public /tuxedo/stops storefront immediately
|
|
||||||
active: true,
|
|
||||||
// forward notes when the RPC supports it (enriched with truck/status/contact)
|
|
||||||
notes: combinedNotes || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const dryRun = args.includes("--dry-run") || args.includes("-n");
|
|
||||||
let xlsxPath = "./Tuxedo_Corn_2026_Tour_Schedule-3.xlsx";
|
|
||||||
const xArg = args.findIndex((a) => a === "--xlsx" || a === "-x");
|
|
||||||
if (xArg !== -1 && args[xArg + 1]) xlsxPath = args[xArg + 1];
|
|
||||||
const doClean = !args.includes("--no-clean");
|
|
||||||
const emitSql = args.includes("--emit-sql") || args.includes("--sql");
|
|
||||||
|
|
||||||
console.log(`[import-tuxedo-stops] xlsx=${xlsxPath} dryRun=${dryRun} clean=${doClean} emitSql=${emitSql}`);
|
|
||||||
|
|
||||||
const { stops, skipped, dirCount, locations: parsedLocations } = await loadStops(xlsxPath);
|
|
||||||
|
|
||||||
console.log(`\nParsed ${stops.length} stops + ${parsedLocations.length} unique locations`);
|
|
||||||
console.log(`Skipped: week-headers=${skipped.weekHeader}, off/cross-dock=${skipped.off}, invalid=${skipped.invalid}, no-date=${skipped.noDate}`);
|
|
||||||
console.log(`Stop Directory raw entries: ${dirCount}\n`);
|
|
||||||
|
|
||||||
if (!stops.length) {
|
|
||||||
console.error("No stops to insert. Check the xlsx path and filters.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show samples
|
|
||||||
console.log("Sample (first 3):");
|
|
||||||
stops.slice(0, 3).forEach((s) => {
|
|
||||||
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
|
|
||||||
if (s.address) console.log(` addr: ${s.address}`);
|
|
||||||
});
|
|
||||||
console.log("\nSample (last 3):");
|
|
||||||
stops.slice(-3).forEach((s) => {
|
|
||||||
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// By week/region/truck
|
|
||||||
const byWeek: Record<string, number> = {};
|
|
||||||
const byRegion: Record<string, number> = {};
|
|
||||||
const byTruck: Record<string, number> = {};
|
|
||||||
for (const s of stops) {
|
|
||||||
byWeek[s.week] = (byWeek[s.week] || 0) + 1;
|
|
||||||
byRegion[s.region] = (byRegion[s.region] || 0) + 1;
|
|
||||||
byTruck[s.truck] = (byTruck[s.truck] || 0) + 1;
|
|
||||||
}
|
|
||||||
console.log("\nBy week:", Object.fromEntries(Object.entries(byWeek).sort(([a], [b]) => Number(a) - Number(b))));
|
|
||||||
console.log("By region:", byRegion);
|
|
||||||
console.log("By truck:", byTruck);
|
|
||||||
|
|
||||||
const dates = [...stops.map((s) => s.date)].sort();
|
|
||||||
console.log(`\nDate range: ${dates[0]} → ${dates[dates.length - 1]}`);
|
|
||||||
|
|
||||||
if (emitSql) {
|
|
||||||
const BATCH = 50;
|
|
||||||
let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`;
|
|
||||||
sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`;
|
|
||||||
sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`;
|
|
||||||
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n`;
|
|
||||||
sql += `-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
|
|
||||||
sql += `BEGIN;\n\n`;
|
|
||||||
|
|
||||||
// Locations first (master directory)
|
|
||||||
if (parsedLocations.length > 0) {
|
|
||||||
sql += `-- ===== LOCATIONS (Stop Directory master records) =====\n`;
|
|
||||||
sql += `DELETE FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}';\n\n`;
|
|
||||||
const locPayload = parsedLocations.map(toLocationRpcRow);
|
|
||||||
const locJson = JSON.stringify(locPayload);
|
|
||||||
sql += `-- ${parsedLocations.length} unique locations from Stop Directory\n`;
|
|
||||||
sql += `SELECT admin_create_locations_batch('${TUXEDO_BRAND_ID}'::uuid, $$${locJson}$$::jsonb);\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stops (dated instances)
|
|
||||||
sql += `-- ===== STOPS (2026 tour schedule) =====\n`;
|
|
||||||
sql += `-- Clear prior 2026 tour stops for brand (date-scoped)\n`;
|
|
||||||
sql += `DELETE FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
|
|
||||||
for (let i = 0; i < stops.length; i += BATCH) {
|
|
||||||
const batchStops = stops.slice(i, i + BATCH);
|
|
||||||
const payload = batchStops.map(toRpcRow);
|
|
||||||
const payloadJson = JSON.stringify(payload);
|
|
||||||
const bnum = Math.floor(i / BATCH) + 1;
|
|
||||||
sql += `-- stops batch ${bnum}/${Math.ceil(stops.length / BATCH)} (${payload.length})\n`;
|
|
||||||
sql += `SELECT admin_create_stops_batch('${TUXEDO_BRAND_ID}'::uuid, $$${payloadJson}$$::jsonb);\n\n`;
|
|
||||||
}
|
|
||||||
sql += `-- Make stops visible (RPC inserts as draft + active=true in payload)\n`;
|
|
||||||
sql += `UPDATE stops SET status = 'active' WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
|
|
||||||
sql += `COMMIT;\n`;
|
|
||||||
const outPath = "db/seeds/2026-tuxedo-tour-stops.sql";
|
|
||||||
fs.writeFileSync(outPath, sql, "utf8");
|
|
||||||
console.log(`\nWrote ${outPath} (locations: ${parsedLocations.length}, stops: ${stops.length}).`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
const stopBatches = Math.ceil(stops.length / 50);
|
|
||||||
console.log(`\n[DRY RUN] Would load for brand:\n - ${parsedLocations.length} locations (full replace)\n - DELETE date-scoped 2026 stops then INSERT ${stops.length} stops in ${stopBatches} batch(es)\n via admin_*_batch RPCs.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString:
|
|
||||||
process.env.DATABASE_ADMIN_URL ??
|
|
||||||
process.env.DATABASE_URL ??
|
|
||||||
"postgres://postgres:postgres@localhost:5432/route_commerce",
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query("BEGIN");
|
|
||||||
|
|
||||||
if (doClean) {
|
|
||||||
const del = await client.query(
|
|
||||||
`DELETE FROM stops WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
|
|
||||||
[TUXEDO_BRAND_ID],
|
|
||||||
);
|
|
||||||
console.log(`\nCleared ${del.rowCount} prior 2026-dated stops for Tuxedo brand.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locations (from Stop Directory) — full replace for the brand from this xlsx
|
|
||||||
if (parsedLocations.length > 0) {
|
|
||||||
const locDel = await client.query(
|
|
||||||
`DELETE FROM locations WHERE brand_id = $1`,
|
|
||||||
[TUXEDO_BRAND_ID],
|
|
||||||
);
|
|
||||||
console.log(`Cleared ${locDel.rowCount} previous locations for Tuxedo brand.`);
|
|
||||||
|
|
||||||
const LOC_BATCH = 100; // locations are fewer and smaller
|
|
||||||
let locTotal = 0;
|
|
||||||
const locBatches = Math.ceil(parsedLocations.length / LOC_BATCH);
|
|
||||||
for (let i = 0; i < parsedLocations.length; i += LOC_BATCH) {
|
|
||||||
const batchLocs = parsedLocations.slice(i, i + LOC_BATCH);
|
|
||||||
const payload = batchLocs.map(toLocationRpcRow);
|
|
||||||
const bnum = Math.floor(i / LOC_BATCH) + 1;
|
|
||||||
process.stdout.write(` Locations batch ${bnum}/${locBatches} (${payload.length}) via admin_create_locations_batch... `);
|
|
||||||
try {
|
|
||||||
await client.query(
|
|
||||||
"SELECT admin_create_locations_batch($1::uuid, $2::jsonb)",
|
|
||||||
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
|
|
||||||
);
|
|
||||||
locTotal += payload.length;
|
|
||||||
console.log("OK");
|
|
||||||
} catch (e: any) {
|
|
||||||
console.log("FAIL");
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` Inserted ${locTotal} locations.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const BATCH = 50;
|
|
||||||
let total = 0;
|
|
||||||
const batches = Math.ceil(stops.length / BATCH);
|
|
||||||
|
|
||||||
for (let i = 0; i < stops.length; i += BATCH) {
|
|
||||||
const batchStops = stops.slice(i, i + BATCH);
|
|
||||||
const payload = batchStops.map(toRpcRow);
|
|
||||||
const bnum = Math.floor(i / BATCH) + 1;
|
|
||||||
|
|
||||||
process.stdout.write(` Batch ${bnum}/${batches} (${payload.length}) via admin_create_stops_batch... `);
|
|
||||||
try {
|
|
||||||
await client.query(
|
|
||||||
"SELECT admin_create_stops_batch($1::uuid, $2::jsonb)",
|
|
||||||
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
|
|
||||||
);
|
|
||||||
total += payload.length;
|
|
||||||
console.log("OK");
|
|
||||||
} catch (e: any) {
|
|
||||||
console.log("FAIL");
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RPC inserts with status='draft' (per prior script); flip to active for public visibility.
|
|
||||||
// Scope by the tour date range so we only touch rows from this import.
|
|
||||||
if (total > 0) {
|
|
||||||
const pub = await client.query(
|
|
||||||
`UPDATE stops SET status = 'active' WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
|
|
||||||
[TUXEDO_BRAND_ID],
|
|
||||||
);
|
|
||||||
console.log(`\nPublished (status=active): ${pub.rowCount} stops.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.query("COMMIT");
|
|
||||||
console.log(`\nDone. Inserted ${parsedLocations.length} locations + ${total} stops for Tuxedo Corn 2026 tour (brand ${TUXEDO_BRAND_ID}).`);
|
|
||||||
console.log("Locations are the master directory; stops are the dated schedule instances.");
|
|
||||||
console.log("They should now appear on /tuxedo/stops and in admin locations UI.");
|
|
||||||
} catch (err) {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
console.error("\nInsert failed (rolled back):", err);
|
|
||||||
process.exitCode = 1;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Apply Postgres migrations from `db/migrations/*.sql` in lexical order.
|
|
||||||
* Wraps each new file in a transaction; tracks applied files in
|
|
||||||
* `_migrations` so re-runs are safe and idempotent.
|
|
||||||
*
|
|
||||||
* The 0001_init.sql (and 0002) are now fully re-runnable (IF NOT EXISTS +
|
|
||||||
* trigger guards) + this script has repair logic for DBs that were
|
|
||||||
* initialized before tracking was introduced.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* npm run migrate
|
|
||||||
* npm run migrate:one # same as above (applies all pending)
|
|
||||||
*/
|
|
||||||
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));
|
|
||||||
|
|
||||||
// Repair for historical DBs (applied before _migrations tracking existed,
|
|
||||||
// or via direct psql / earlier tooling). If the core objects are present
|
|
||||||
// we record the filename so future deploys (and server-side recovery runs)
|
|
||||||
// treat 0001/0002 as done without re-executing the large init script.
|
|
||||||
async function ensureTracked(filename, existenceCheckSql) {
|
|
||||||
if (appliedSet.has(filename)) return;
|
|
||||||
try {
|
|
||||||
const { rows } = await client.query(existenceCheckSql);
|
|
||||||
if (rows.length > 0) {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT (filename) DO NOTHING`,
|
|
||||||
[filename]
|
|
||||||
);
|
|
||||||
console.log(`✓ ${filename} (objects present in DB; repaired tracking)`);
|
|
||||||
appliedSet.add(filename);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Non-fatal: the check may fail on a brand-new DB or with limited perms.
|
|
||||||
// We'll let the normal apply path handle it.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureTracked(
|
|
||||||
"0001_init.sql",
|
|
||||||
"SELECT 1 FROM information_schema.tables WHERE table_name = 'admin_users' LIMIT 1"
|
|
||||||
);
|
|
||||||
await ensureTracked(
|
|
||||||
"0002_admin_password.sql",
|
|
||||||
"SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'password_hash' LIMIT 1"
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Post-migration verification.
|
|
||||||
* Called by .gitea/workflows/deploy.yml after npm run migrate:one.
|
|
||||||
* Confirms critical table admin_users is queryable.
|
|
||||||
*/
|
|
||||||
const { Client } = require("pg");
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const url = process.env.DATABASE_URL;
|
|
||||||
if (!url) {
|
|
||||||
console.error("No DATABASE_URL");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const c = new Client({ connectionString: url });
|
|
||||||
await c.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("=== Post-migration verification: critical table admin_users must exist ===");
|
|
||||||
await c.query("SELECT 1 FROM admin_users LIMIT 1");
|
|
||||||
console.log("✓ admin_users table exists and is queryable");
|
|
||||||
process.exit(0);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("✗ FATAL: admin_users relation missing after migrate:", e.message);
|
|
||||||
console.error("The deploy cannot continue. The secret DATABASE_URL must have had db/migrations/0001_init.sql applied successfully.");
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await c.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Preflight check + migration repair runner.
|
|
||||||
* Called by .gitea/workflows/deploy.yml before running migrations.
|
|
||||||
*
|
|
||||||
* 1. Verify neon_auth schema exists (prerequisite for 0001_init.sql FKs)
|
|
||||||
* 2. Ensure 0001_init.sql is tracked in _migrations if admin_users already exists
|
|
||||||
* (repair for DBs that had 0001_init.sql applied before _migrations was added)
|
|
||||||
*/
|
|
||||||
const { Client } = require("pg");
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const url = process.env.DATABASE_URL;
|
|
||||||
if (!url) {
|
|
||||||
console.error("No DATABASE_URL");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const c = new Client({ connectionString: url });
|
|
||||||
await c.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Pre-flight: check neon_auth schema
|
|
||||||
console.log("=== Pre-flight: checking for neon_auth schema (required by 0001_init.sql for FKs to neon_auth.user and related RPCs) ===");
|
|
||||||
const schemaRes = await c.query(
|
|
||||||
"SELECT 1 FROM information_schema.schemata WHERE schema_name = 'neon_auth'"
|
|
||||||
);
|
|
||||||
if (schemaRes.rows.length === 0) {
|
|
||||||
console.error("✗ FATAL: neon_auth schema does not exist in the target database.");
|
|
||||||
console.error("Enable Neon Auth on the Neon project/branch that this DATABASE_URL points to first.");
|
|
||||||
console.error("Run: neonctl neon-auth (or equivalent) against the correct Neon branch.");
|
|
||||||
console.error("The neon_auth schema is created by Neon Auth and is a prerequisite for 0001_init.sql.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log("✓ neon_auth schema exists");
|
|
||||||
|
|
||||||
// 2. Pre-repair: ensure 0001_init.sql is tracked if admin_users already exists
|
|
||||||
// This handles DBs where 0001_init.sql was applied before _migrations tracking existed.
|
|
||||||
console.log("=== Pre-repair: checking _migrations tracking (best-effort) ===");
|
|
||||||
try {
|
|
||||||
const tableRes = await c.query(
|
|
||||||
"SELECT 1 FROM information_schema.tables WHERE table_name = 'admin_users' LIMIT 1"
|
|
||||||
);
|
|
||||||
if (tableRes.rows.length > 0) {
|
|
||||||
await c.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS _migrations (
|
|
||||||
filename TEXT PRIMARY KEY,
|
|
||||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
INSERT INTO _migrations (filename)
|
|
||||||
VALUES ('0001_init.sql')
|
|
||||||
ON CONFLICT (filename) DO NOTHING;
|
|
||||||
`);
|
|
||||||
console.log("✓ 0001_init.sql tracking repaired (admin_users already present)");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// best-effort
|
|
||||||
console.log("(pre-repair skipped: " + e.message + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Preflight complete. Proceeding to migrations...");
|
|
||||||
process.exit(0);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Preflight failed:", e.message);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await c.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
/**
|
|
||||||
* Provision an admin user by email.
|
|
||||||
* Run: npx tsx scripts/provision-admin.ts [email] [role]
|
|
||||||
*
|
|
||||||
* Role options: platform_admin, brand_admin, store_employee
|
|
||||||
*/
|
|
||||||
import pg from "pg";
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
|
|
||||||
// Load .env.local (or .env.production) manually so the script works in prod bootstrap
|
|
||||||
const envFiles = [".env.local", ".env.production"];
|
|
||||||
for (const f of envFiles) {
|
|
||||||
const envPath = path.join(process.cwd(), f);
|
|
||||||
if (fs.existsSync(envPath)) {
|
|
||||||
const envContent = fs.readFileSync(envPath, "utf-8");
|
|
||||||
for (const line of envContent.split("\n")) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (trimmed && !trimmed.startsWith("#")) {
|
|
||||||
const [key, ...valueParts] = trimmed.split("=");
|
|
||||||
const k = key.trim();
|
|
||||||
if (!process.env[k]) {
|
|
||||||
process.env[k] = valueParts.join("=").trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`[provision] loaded ${f}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { Pool } = pg;
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const email = process.argv[2] ?? "admin@example.com";
|
|
||||||
const role = process.argv[3] ?? "platform_admin";
|
|
||||||
|
|
||||||
console.log("DATABASE_URL:", process.env.DATABASE_URL ? "set" : "NOT SET");
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Find the Neon Auth user by email
|
|
||||||
const userResult = await pool.query(
|
|
||||||
"SELECT id FROM neon_auth.user WHERE email = $1",
|
|
||||||
[email]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (userResult.rows.length === 0) {
|
|
||||||
console.error(`User ${email} not found in neon_auth.user`);
|
|
||||||
console.error("Please sign up first at /login");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = userResult.rows[0].id;
|
|
||||||
console.log(`Found Neon Auth user: ${userId} (${email})`);
|
|
||||||
|
|
||||||
// Check if already in admin_users
|
|
||||||
const existingAdmin = await pool.query(
|
|
||||||
"SELECT id FROM admin_users WHERE user_id = $1",
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
|
|
||||||
let adminUserId: string;
|
|
||||||
|
|
||||||
if (existingAdmin.rows.length > 0) {
|
|
||||||
adminUserId = existingAdmin.rows[0].id;
|
|
||||||
console.log(`User already in admin_users: ${adminUserId}`);
|
|
||||||
} else {
|
|
||||||
// Insert into admin_users
|
|
||||||
const insertResult = await pool.query(
|
|
||||||
`INSERT INTO admin_users (user_id, email, name, role,
|
|
||||||
can_manage_orders, can_manage_products, can_manage_stops,
|
|
||||||
can_manage_customers, can_manage_wholesale, can_manage_billing,
|
|
||||||
can_manage_settings, can_manage_water_log, can_manage_time_tracking,
|
|
||||||
can_manage_route_trace, can_manage_reports, can_manage_communications)
|
|
||||||
VALUES ($1, $2, $3, $4, true, true, true, true, true, true, true, true, true, true, true, true)
|
|
||||||
RETURNING id`,
|
|
||||||
[userId, email, email.split("@")[0], role]
|
|
||||||
);
|
|
||||||
adminUserId = insertResult.rows[0].id;
|
|
||||||
console.log(`Created admin_users record: ${adminUserId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get brands
|
|
||||||
const brandsResult = await pool.query("SELECT id, name, slug FROM brands LIMIT 10");
|
|
||||||
|
|
||||||
if (brandsResult.rows.length === 0) {
|
|
||||||
console.error("No brands found. Please create a brand first.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\nAvailable brands:");
|
|
||||||
brandsResult.rows.forEach((b, i) => {
|
|
||||||
console.log(` ${i + 1}. ${b.name} (${b.slug}) - ${b.id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Link to first brand (or create a default one)
|
|
||||||
const brand = brandsResult.rows[0];
|
|
||||||
|
|
||||||
// Check if already linked
|
|
||||||
const existingLink = await pool.query(
|
|
||||||
"SELECT 1 FROM admin_user_brands WHERE admin_user_id = $1 AND brand_id = $2",
|
|
||||||
[adminUserId, brand.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingLink.rows.length > 0) {
|
|
||||||
console.log(`Already linked to brand: ${brand.name}`);
|
|
||||||
} else {
|
|
||||||
await pool.query(
|
|
||||||
"INSERT INTO admin_user_brands (admin_user_id, brand_id) VALUES ($1, $2)",
|
|
||||||
[adminUserId, brand.id]
|
|
||||||
);
|
|
||||||
console.log(`Linked to brand: ${brand.name} (${brand.slug})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n✅ ${email} is now provisioned as ${role}!`);
|
|
||||||
console.log(` They can access the admin at your production URL /admin (sign in first if needed).`);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* Seed an admin user for development.
|
|
||||||
* Run: npx tsx scripts/seed-admin.ts [email] [password] [name]
|
|
||||||
*/
|
|
||||||
import { config } from "dotenv";
|
|
||||||
config({ path: ".env.local" });
|
|
||||||
import pg from "pg";
|
|
||||||
|
|
||||||
const { Pool } = pg;
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const email = process.argv[2] ?? "admin@test.com";
|
|
||||||
const password = process.argv[3] ?? "Admin1234!";
|
|
||||||
const name = process.argv[4] ?? "Admin";
|
|
||||||
|
|
||||||
const baseUrl = process.env.NEON_AUTH_BASE_URL!;
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Create or get brand
|
|
||||||
const brandResult = await pool.query(`
|
|
||||||
INSERT INTO brands (name, slug, plan_tier)
|
|
||||||
VALUES ('Demo Brand', 'demo', 'enterprise')
|
|
||||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
|
||||||
RETURNING id, name, slug
|
|
||||||
`);
|
|
||||||
const brand = brandResult.rows[0];
|
|
||||||
console.log("✓ Brand:", brand.name, `(${brand.id})`);
|
|
||||||
|
|
||||||
// 2. Create user in Neon Auth via direct API
|
|
||||||
const res = await fetch(`${baseUrl}/sign-up/email`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Origin": "http://localhost:4000",
|
|
||||||
"x-neon-auth-proxy": "node",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email, password, name, callbackURL: "/admin" }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok && data.code !== "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
|
||||||
console.error("✗ Failed to create user:", data);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = data.user?.id;
|
|
||||||
console.log("✓ Neon Auth user:", email, userId ? `(${userId})` : "(already existed)");
|
|
||||||
|
|
||||||
// 3. Verify email in Neon Auth DB
|
|
||||||
if (userId) {
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`,
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
console.log("✓ Email verified in DB");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Get user ID from neon_auth.user by email
|
|
||||||
const neonUser = await pool.query(
|
|
||||||
`SELECT id FROM neon_auth.user WHERE email = $1`,
|
|
||||||
[email]
|
|
||||||
);
|
|
||||||
const neonUserId = neonUser.rows[0]?.id;
|
|
||||||
if (!neonUserId) {
|
|
||||||
console.error("✗ Could not find user in neon_auth.user");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Insert into admin_users (upsert)
|
|
||||||
const adminResult = await pool.query(`
|
|
||||||
INSERT INTO admin_users (user_id, email, name, role)
|
|
||||||
VALUES ($1, $2, $3, 'brand_admin')
|
|
||||||
ON CONFLICT (email) DO UPDATE SET user_id = EXCLUDED.user_id, name = EXCLUDED.name, role = EXCLUDED.role
|
|
||||||
RETURNING id
|
|
||||||
`, [neonUserId, email, name]);
|
|
||||||
const adminUser = adminResult.rows[0];
|
|
||||||
console.log("✓ Admin user:", email, `(${adminUser.id})`);
|
|
||||||
|
|
||||||
// 6. Link to brand
|
|
||||||
await pool.query(`
|
|
||||||
INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
`, [adminUser.id, brand.id]);
|
|
||||||
console.log("✓ Linked to brand:", brand.name);
|
|
||||||
|
|
||||||
console.log(`\n🎉 All set! Log in at http://localhost:4000/login`);
|
|
||||||
console.log(` Email: ${email}`);
|
|
||||||
console.log(` Password: ${password}`);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Seed Tuxedo Corn 2026 tour: locations + stops.
|
|
||||||
*
|
|
||||||
* Uses sql`...` tagged template from @neondatabase/serverless (WebSocket mode)
|
|
||||||
* for INSERTs — this is the only Neon serverless API that actually executes writes.
|
|
||||||
*
|
|
||||||
* Run with:
|
|
||||||
* node scripts/seed-tuxedo-2026.js
|
|
||||||
*
|
|
||||||
* Requires DATABASE_URL in .env.local (or pass explicitly).
|
|
||||||
*/
|
|
||||||
require("dotenv").config({ path: ".env.local" });
|
|
||||||
const { neon } = require("@neondatabase/serverless");
|
|
||||||
const ExcelJS = require("exceljs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
|
||||||
const YEAR = 2026;
|
|
||||||
|
|
||||||
const url = process.env.DATABASE_URL;
|
|
||||||
if (!url) {
|
|
||||||
console.error("❌ DATABASE_URL not set in .env.local");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanUrl = url.replace(/[?&]channel_binding=require/gi, "").trim();
|
|
||||||
// Use direct compute endpoint with WebSocket for full DDL + DML support
|
|
||||||
const directUrl = cleanUrl.replace("-pooler.", ".");
|
|
||||||
const sql = neon(directUrl, { webSocket: true });
|
|
||||||
|
|
||||||
const 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",
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseExcelDate(s) {
|
|
||||||
if (!s) return null;
|
|
||||||
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
|
|
||||||
if (!m) return null;
|
|
||||||
const mm = MONTH_MAP[m[1]];
|
|
||||||
if (!mm) return null;
|
|
||||||
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTimeRange(s) {
|
|
||||||
if (!s) return "";
|
|
||||||
return String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseXlsx(xlsxPath) {
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
|
||||||
await workbook.xlsx.readFile(xlsxPath);
|
|
||||||
|
|
||||||
// --- Locations from Stop Directory ---
|
|
||||||
const locSheet = workbook.getWorksheet("Stop Directory");
|
|
||||||
const locations = [];
|
|
||||||
const seenLoc = new Set();
|
|
||||||
|
|
||||||
if (locSheet) {
|
|
||||||
locSheet.eachRow((row, rowNum) => {
|
|
||||||
if (rowNum === 1) return;
|
|
||||||
// Column mapping via getCell():
|
|
||||||
// col1=Truck, col2=City, col3=State, col4=Host(venue name), col5=Address, col6=Phone, col7=Contact, col12=Notes
|
|
||||||
const rawCity = String(row.getCell(2).value || "").trim();
|
|
||||||
const rawState = String(row.getCell(3).value || "").trim();
|
|
||||||
const rawName = String(row.getCell(4).value || "").trim();
|
|
||||||
const rawAddress = String(row.getCell(5).value || "").trim();
|
|
||||||
if (!rawCity || !rawName) return;
|
|
||||||
const key = `${rawName}|${rawCity}|${rawState}`;
|
|
||||||
if (seenLoc.has(key)) return;
|
|
||||||
seenLoc.add(key);
|
|
||||||
locations.push({
|
|
||||||
name: rawName,
|
|
||||||
address: rawAddress || null,
|
|
||||||
city: rawCity,
|
|
||||||
state: rawState,
|
|
||||||
zip: null,
|
|
||||||
phone: String(row.getCell(6).value || "").trim() || null,
|
|
||||||
contact_name: String(row.getCell(7).value || "").trim() || null,
|
|
||||||
contact_email: null,
|
|
||||||
notes: String(row.getCell(12).value || "").trim() || null,
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log(`Parsed ${locations.length} locations`);
|
|
||||||
|
|
||||||
// --- Stops from Full Schedule ---
|
|
||||||
const schedSheet = workbook.getWorksheet("Full Schedule");
|
|
||||||
const stops = [];
|
|
||||||
const venueAddressMap = new Map();
|
|
||||||
|
|
||||||
if (locSheet) {
|
|
||||||
locSheet.eachRow((row, rowNum) => {
|
|
||||||
if (rowNum === 1) return;
|
|
||||||
const name = String(row.getCell(4).value || "").trim();
|
|
||||||
const address = String(row.getCell(5).value || "").trim();
|
|
||||||
if (name && address) venueAddressMap.set(name, address);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schedSheet) {
|
|
||||||
schedSheet.eachRow((row) => {
|
|
||||||
const rowValues = row.values || [];
|
|
||||||
// Column mapping (1-indexed, col1=null):
|
|
||||||
// col2=Wk, col3=Type, col4=Date, col5=Day, col6=City/Location, col7=Host/Venue, col8=Time, col9=Truck, col10=Status, col11=Notes
|
|
||||||
const firstCell = String(rowValues[1] || "").trim();
|
|
||||||
// Skip title rows, week headers ("Wk 1"), OFF, Cross-Dock
|
|
||||||
if (!firstCell || /^Wk\s*\d+/i.test(firstCell) || firstCell === "OFF" || firstCell === "Cross-Dock") return;
|
|
||||||
|
|
||||||
const dateStr = parseExcelDate(rowValues[3]);
|
|
||||||
if (!dateStr) return;
|
|
||||||
|
|
||||||
const cityStateRaw = String(rowValues[5] || "").trim();
|
|
||||||
const cityParts = cityStateRaw.split(",").map((s) => s.trim());
|
|
||||||
const city = cityParts[0] || "";
|
|
||||||
const state = cityParts[1] || "";
|
|
||||||
const location = String(rowValues[6] || "").trim();
|
|
||||||
if (!city || !state || !location) return;
|
|
||||||
|
|
||||||
const rawTime = rowValues[7];
|
|
||||||
const time = rawTime ? parseTimeRange(rawTime) : "";
|
|
||||||
const truck = String(rowValues[8] || "").trim();
|
|
||||||
const statusText = String(rowValues[9] || "").trim();
|
|
||||||
const notesText = String(rowValues[10] || "").trim();
|
|
||||||
|
|
||||||
const venueAddress = venueAddressMap.get(location) || null;
|
|
||||||
const notes = [truck ? `Truck: ${truck}` : "", statusText, notesText].filter(Boolean).join(" | ");
|
|
||||||
|
|
||||||
stops.push({
|
|
||||||
name: `${location} - ${city}, ${state}`,
|
|
||||||
location,
|
|
||||||
address: venueAddress,
|
|
||||||
city,
|
|
||||||
state,
|
|
||||||
zip: null,
|
|
||||||
date: dateStr,
|
|
||||||
time,
|
|
||||||
cutoff_date: null,
|
|
||||||
status: "active",
|
|
||||||
is_public: true,
|
|
||||||
notes: notes || null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log(`Parsed ${stops.length} stops`);
|
|
||||||
return { locations, stops };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedLocations(locs) {
|
|
||||||
if (locs.length === 0) return;
|
|
||||||
await sql`DELETE FROM locations WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid`;
|
|
||||||
for (const loc of locs) {
|
|
||||||
await sql`INSERT INTO locations (brand_id, name, address, city, state, zip, phone, contact_name, contact_email, notes, active)
|
|
||||||
VALUES (${TUXEDO_BRAND_ID}::uuid, ${loc.name}, ${loc.address}, ${loc.city}, ${loc.state}, ${loc.zip}, ${loc.phone}, ${loc.contact_name}, ${loc.contact_email}, ${loc.notes}, ${loc.active})`;
|
|
||||||
}
|
|
||||||
console.log(`Seeded ${locs.length} locations`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedStops(stops) {
|
|
||||||
if (stops.length === 0) return;
|
|
||||||
const minDate = `${YEAR}-07-01`;
|
|
||||||
const maxDate = `${YEAR}-09-30`;
|
|
||||||
await sql`DELETE FROM stops WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid AND date >= ${minDate} AND date <= ${maxDate}`;
|
|
||||||
for (const stop of stops) {
|
|
||||||
await sql`INSERT INTO stops (brand_id, name, location, address, city, state, zip, date, "time", cutoff_date, status, is_public, notes)
|
|
||||||
VALUES (${TUXEDO_BRAND_ID}::uuid, ${stop.name}, ${stop.location}, ${stop.address}, ${stop.city}, ${stop.state}, ${stop.zip}, ${stop.date}, ${stop.time}, ${stop.cutoff_date}, ${stop.status}, ${stop.is_public}, ${stop.notes})`;
|
|
||||||
}
|
|
||||||
console.log(`Seeded ${stops.length} stops`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const xlsxPath = path.join(__dirname, "..", "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx");
|
|
||||||
console.log("Parsing:", xlsxPath);
|
|
||||||
const { locations, stops } = await parseXlsx(xlsxPath);
|
|
||||||
if (locations.length === 0 && stops.length === 0) {
|
|
||||||
console.error("❌ No data parsed from xlsx");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log(`Seeding ${locations.length} locations + ${stops.length} stops for Tuxedo Corn...`);
|
|
||||||
await seedLocations(locations);
|
|
||||||
await seedStops(stops);
|
|
||||||
const locCount = await sql.query(`SELECT COUNT(*) FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
|
|
||||||
const stopCount = await sql.query(`SELECT COUNT(*) FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
|
|
||||||
console.log(`\n✅ Done — ${locCount[0].count} locations, ${stopCount[0].count} stops in DB`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error("❌ Seed failed:", e.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { config } from "dotenv";
|
|
||||||
config({ path: ".env.local" });
|
|
||||||
import pg from "pg";
|
|
||||||
|
|
||||||
const { Pool } = pg;
|
|
||||||
|
|
||||||
const TUXEDO = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// brand_settings
|
|
||||||
const bs = await pool.query(
|
|
||||||
`INSERT INTO brand_settings (brand_id, legal_business_name, phone, email, logo_url, tagline, from_email)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
ON CONFLICT (brand_id) DO UPDATE SET legal_business_name = EXCLUDED.legal_business_name
|
|
||||||
RETURNING brand_id, legal_business_name`,
|
|
||||||
[TUXEDO, "Tuxedo Corn LLC", "970-555-0100", "info@tuxedocorn.com",
|
|
||||||
"/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png",
|
|
||||||
"Farm-Fresh Sweet Corn, Delivered", "orders@tuxedocorn.com"]
|
|
||||||
);
|
|
||||||
console.log("brand_settings:", bs.rows[0]);
|
|
||||||
|
|
||||||
// wholesale_settings
|
|
||||||
const ws = await pool.query(
|
|
||||||
`INSERT INTO wholesale_settings (brand_id, require_approval, pickup_location, fob_location, from_email)
|
|
||||||
VALUES ($1, true, $2, $3, $4)
|
|
||||||
ON CONFLICT (brand_id) DO UPDATE SET pickup_location = EXCLUDED.pickup_location
|
|
||||||
RETURNING brand_id, pickup_location`,
|
|
||||||
[TUXEDO, "59751 David Road, Olathe, CO 81425", "FOB Olathe, CO", "orders@tuxedocorn.com"]
|
|
||||||
);
|
|
||||||
console.log("wholesale_settings:", ws.rows[0]);
|
|
||||||
|
|
||||||
console.log("\nDone!");
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => { console.error(e.message); process.exit(1); });
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
#!/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 = "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx" # run from repo root; override with --xlsx /path/to/file.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,11 +0,0 @@
|
|||||||
import { config } from "dotenv";
|
|
||||||
config({ path: ".env.local" });
|
|
||||||
import pg from "pg";
|
|
||||||
const { Pool } = pg;
|
|
||||||
async function main() {
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
||||||
const r = await pool.query(`SELECT id, email, "emailVerified" FROM neon_auth.user WHERE email = 'admin@test.com'`);
|
|
||||||
console.log(JSON.stringify(r.rows, null, 2));
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
main();
|
|
||||||
+11
-11
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { pool } from "@/lib/db";
|
import { svcRpc } from "@/lib/svc-fetch";
|
||||||
|
|
||||||
type AdminActionPayload = {
|
type AdminActionPayload = {
|
||||||
action_type: "create" | "update" | "delete";
|
action_type: "create" | "update" | "delete";
|
||||||
@@ -21,33 +21,33 @@ type UserActivityPayload = {
|
|||||||
|
|
||||||
export async function logAdminAction(payload: AdminActionPayload): Promise<void> {
|
export async function logAdminAction(payload: AdminActionPayload): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await pool.query("SELECT log_admin_action($1::jsonb)", [
|
await svcRpc("log_admin_action", {
|
||||||
JSON.stringify({
|
p_payload: {
|
||||||
action_type: payload.action_type,
|
action_type: payload.action_type,
|
||||||
admin_id: payload.admin_id ?? null,
|
admin_id: payload.admin_id ?? null,
|
||||||
admin_email: payload.admin_email ?? null,
|
admin_email: payload.admin_email ?? null,
|
||||||
affected_user_id: payload.affected_user_id ?? null,
|
affected_user_id: payload.affected_user_id ?? null,
|
||||||
brand_id: payload.brand_id ?? null,
|
brand_id: payload.brand_id ?? null,
|
||||||
details: payload.details ?? {},
|
details: payload.details ?? {},
|
||||||
}),
|
},
|
||||||
]);
|
});
|
||||||
} catch {
|
} catch (e) {
|
||||||
// logging failed silently
|
// logging failed silently
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logUserActivity(payload: UserActivityPayload): Promise<void> {
|
export async function logUserActivity(payload: UserActivityPayload): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await pool.query("SELECT log_user_activity($1::jsonb)", [
|
await svcRpc("log_user_activity", {
|
||||||
JSON.stringify({
|
p_payload: {
|
||||||
user_id: payload.user_id,
|
user_id: payload.user_id,
|
||||||
activity_type: payload.activity_type,
|
activity_type: payload.activity_type,
|
||||||
details: payload.details ?? {},
|
details: payload.details ?? {},
|
||||||
ip_address: payload.ip_address ?? null,
|
ip_address: payload.ip_address ?? null,
|
||||||
user_agent: payload.user_agent ?? null,
|
user_agent: payload.user_agent ?? null,
|
||||||
}),
|
},
|
||||||
]);
|
});
|
||||||
} catch {
|
} catch (e) {
|
||||||
// logging failed silently
|
// logging failed silently
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
|
export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> {
|
||||||
|
try {
|
||||||
|
// Upsert dev platform_admin record
|
||||||
|
const res = await pool.query(
|
||||||
|
`INSERT INTO admin_users (
|
||||||
|
user_id, brand_id, role, active,
|
||||||
|
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
|
||||||
|
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log,
|
||||||
|
can_manage_reports, can_manage_settings, must_change_password
|
||||||
|
) VALUES (
|
||||||
|
$1, NULL, 'platform_admin', true,
|
||||||
|
true, true, true, true,
|
||||||
|
true, true, true, true,
|
||||||
|
true, true, false
|
||||||
|
)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET active = EXCLUDED.active
|
||||||
|
RETURNING id, role`,
|
||||||
|
[DEV_ADMIN_UID]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.rows.length === 0) {
|
||||||
|
return { success: false, error: "Failed to upsert dev admin" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, uid: DEV_ADMIN_UID };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : "Unknown error";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,60 +1,25 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import "server-only";
|
import { cookies } from "next/headers";
|
||||||
import { getSession } from "@/lib/auth";
|
import { svcRpc } from "@/lib/svc-fetch";
|
||||||
import { setUserPassword } from "@/lib/auth";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
|
|
||||||
const MIN_PASSWORD_LENGTH = 8;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change a user's password. Requires admin privileges.
|
|
||||||
*
|
|
||||||
* Use this for admin-initiated password resets. For self-service
|
|
||||||
* password changes, users should use the forgot password flow.
|
|
||||||
*/
|
|
||||||
export async function updatePasswordAction(
|
export async function updatePasswordAction(
|
||||||
userId: string,
|
|
||||||
newPassword: string
|
newPassword: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ error?: string }> {
|
||||||
// Verify the caller is an admin
|
const cookieStore = await cookies();
|
||||||
const adminUser = await getAdminUser();
|
const uid =
|
||||||
if (!adminUser) {
|
cookieStore.get("rc_auth_uid")?.value ??
|
||||||
return { success: false, error: "Not authenticated. Please log in again." };
|
cookieStore.get("rc_uid")?.value;
|
||||||
|
|
||||||
|
if (!uid) {
|
||||||
|
return { error: "Not authenticated. Please log in again." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adminUser.role !== "platform_admin") {
|
const { error } = await svcRpc("update_user_password", {
|
||||||
return { success: false, error: "Only platform admins can change passwords." };
|
p_user_id: uid,
|
||||||
}
|
p_password: newPassword,
|
||||||
|
|
||||||
// Validate password
|
|
||||||
if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
||||||
return { success: false, error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.` };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await setUserPassword({
|
|
||||||
userId,
|
|
||||||
newPassword,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (error) return { error: error.message };
|
||||||
console.error("[updatePassword] Failed:", result.error);
|
return {};
|
||||||
return { success: false, error: result.error.message ?? "Failed to update password." };
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[updatePassword] Password updated for user:", userId);
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[updatePassword] Unexpected error:", err);
|
|
||||||
return { success: false, error: "An unexpected error occurred." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current session user ID. Used by the change-password UI.
|
|
||||||
*/
|
|
||||||
export async function getCurrentUserId(): Promise<string | null> {
|
|
||||||
const { data: session } = await getSession();
|
|
||||||
return session?.user?.id ?? null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateAdminProfileResult =
|
||||||
|
| { success: true }
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
export async function updateAdminProfileAction(
|
||||||
|
id: string,
|
||||||
|
displayName: string | null,
|
||||||
|
phoneNumber: string | null
|
||||||
|
): Promise<UpdateAdminProfileResult> {
|
||||||
|
try {
|
||||||
|
await pool.query("SELECT update_admin_user($1, $2, $3)", [
|
||||||
|
id,
|
||||||
|
displayName,
|
||||||
|
phoneNumber,
|
||||||
|
]);
|
||||||
|
revalidatePath("/admin/me");
|
||||||
|
return { success: true };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : "Failed to update profile";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,149 +1,37 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import "server-only";
|
import * as authAdmin from "@/lib/auth-admin";
|
||||||
import { randomBytes } from "crypto";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
import {
|
|
||||||
requestPasswordReset as neonAuthRequestPasswordReset,
|
|
||||||
setUserPassword as neonAuthSetUserPassword,
|
|
||||||
} from "@/lib/auth";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
|
|
||||||
export type ResetAdminPasswordResult =
|
export type ResetAdminPasswordResult =
|
||||||
| { success: true; tempPassword: string; method: "set" }
|
| { success: true; tempPassword: string }
|
||||||
| { success: true; method: "reset_link_sent"; message: string }
|
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emergency recovery action — resets the password for the specified
|
* Emergency recovery action — only usable in development or when the caller
|
||||||
* email so the platform admin can hand a working credential to the
|
* already has service role access. Resets the password for the specified email
|
||||||
* user.
|
* and returns the temp password so it can be displayed to the user.
|
||||||
*
|
|
||||||
* Tries, in order:
|
|
||||||
* 1. `auth.admin.setUserPassword({ userId, newPassword })` — instant,
|
|
||||||
* works when the caller has `role='admin'` in `neon_auth.user`.
|
|
||||||
* 2. Falls back to `requestPasswordReset({ email, redirectTo })` —
|
|
||||||
* public Neon Auth endpoint, always works, but the user has to
|
|
||||||
* click the link in the email. The fallback covers the common
|
|
||||||
* case where `provision-admin.ts` did not promote the caller to
|
|
||||||
* `role='admin'` in Neon Auth.
|
|
||||||
*
|
|
||||||
* Authorization: platform_admin only. The dev-session platform_admin
|
|
||||||
* shim is honored (same as the rest of the admin actions).
|
|
||||||
*/
|
*/
|
||||||
export async function resetAdminPassword(
|
export async function resetAdminPassword(
|
||||||
email: string,
|
email: string,
|
||||||
|
newPassword: string
|
||||||
): Promise<ResetAdminPasswordResult> {
|
): Promise<ResetAdminPasswordResult> {
|
||||||
// 1. Authz check.
|
// Look up auth user by email
|
||||||
const me = await getAdminUser();
|
const { data: users, error: listError } = await authAdmin.listUsers({ searchValue: email });
|
||||||
if (!me) {
|
if (listError) {
|
||||||
return { success: false, error: "Not authenticated." };
|
return { success: false, error: "Could not list users: " + listError.message };
|
||||||
}
|
|
||||||
if (me.role !== "platform_admin") {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Only platform admins can reset passwords.",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedEmail = email.trim().toLowerCase();
|
const authUser = (users ?? []).find((u) => u.email?.toLowerCase() === email.toLowerCase());
|
||||||
if (!normalizedEmail) {
|
if (!authUser) {
|
||||||
return { success: false, error: "Email is required." };
|
return { success: false, error: "No auth user found for that email address." };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Look up the Neon Auth user id by email.
|
// Update password via the better-auth admin wrapper (uses `update_user_password` RPC internally)
|
||||||
const { rows } = await query<{ id: string }>(
|
const { error: updateError } = await authAdmin.setUserPassword(authUser.id, newPassword);
|
||||||
`SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1`,
|
|
||||||
[normalizedEmail],
|
if (updateError) {
|
||||||
);
|
return { success: false, error: updateError.message };
|
||||||
const user = rows[0];
|
|
||||||
if (!user) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `No Neon Auth user found for ${normalizedEmail}.`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Generate a strong temp password server-side. 16 bytes → 32
|
return { success: true, tempPassword: newPassword };
|
||||||
// base64url chars; mix in a symbol so it satisfies typical
|
|
||||||
// password policies.
|
|
||||||
const tempPassword = `${randomBytes(18).toString("base64url")}!`;
|
|
||||||
|
|
||||||
// 4. Try the privileged admin endpoint.
|
|
||||||
try {
|
|
||||||
const adminResult = await neonAuthSetUserPassword({
|
|
||||||
userId: user.id,
|
|
||||||
newPassword: tempPassword,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (adminResult?.error) {
|
|
||||||
const code = adminResult.error.code ?? "";
|
|
||||||
const canFallback =
|
|
||||||
code === "FORBIDDEN" ||
|
|
||||||
code === "UNAUTHORIZED" ||
|
|
||||||
code === "INTERNAL_SERVER_ERROR";
|
|
||||||
console.warn(
|
|
||||||
"[resetAdminPassword] setUserPassword failed (code=%s), will%s fall back: %s",
|
|
||||||
code,
|
|
||||||
canFallback ? "" : " NOT",
|
|
||||||
adminResult.error.message ?? "",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!canFallback) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: adminResult.error.message ?? `setUserPassword failed: ${code}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// fall through to the public reset-link path below
|
|
||||||
} else {
|
|
||||||
// Success — flip must_change_password so the user is forced
|
|
||||||
// to pick a new password on next sign-in.
|
|
||||||
try {
|
|
||||||
await query(
|
|
||||||
`UPDATE admin_users SET must_change_password = true WHERE user_id = $1`,
|
|
||||||
[user.id],
|
|
||||||
);
|
|
||||||
} catch (flagErr) {
|
|
||||||
console.warn(
|
|
||||||
"[resetAdminPassword] failed to set must_change_password (non-fatal):",
|
|
||||||
flagErr,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { success: true, tempPassword, method: "set" };
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[resetAdminPassword] setUserPassword threw:", err);
|
|
||||||
// network / unexpected — try the fallback below
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Fallback: send a password-reset email via the public endpoint.
|
|
||||||
try {
|
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
|
|
||||||
const resetResult = await neonAuthRequestPasswordReset({
|
|
||||||
email: normalizedEmail,
|
|
||||||
redirectTo: `${siteUrl}/reset-password`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resetResult?.error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
resetResult.error.message ??
|
|
||||||
resetResult.error.code ??
|
|
||||||
"Failed to send reset email",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
method: "reset_link_sent",
|
|
||||||
message: `Set-password is not available for this account. Sent a password-reset email to ${normalizedEmail} instead — share that link with the user.`,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+423
-495
@@ -1,16 +1,14 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import "server-only";
|
import { cookies, headers } from "next/headers";
|
||||||
import { query, withTx } from "@/lib/db";
|
import { api as publicApi } from "@/lib/api";
|
||||||
import {
|
import { auth } from "@/lib/auth";
|
||||||
createUser as neonAuthCreateUser,
|
import { svcApi, svcRpc } from "@/lib/svc-fetch";
|
||||||
requestPasswordReset as neonAuthRequestPasswordReset,
|
import * as authAdmin from "@/lib/auth-admin";
|
||||||
} from "@/lib/auth";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
|
|
||||||
export type AdminUserRow = {
|
export type AdminUserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string | null;
|
user_id: string;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
phone_number: string | null;
|
phone_number: string | null;
|
||||||
@@ -73,17 +71,144 @@ export type UpdateAdminUserInput = {
|
|||||||
phone_number?: string | null;
|
phone_number?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Row mapping ────────────────────────────────────────────────────────────
|
// ─── Auth helper: validate session via better-auth ──────────────────────────
|
||||||
//
|
|
||||||
// `admin_users` schema (after migration 204 + 034 + 037):
|
/**
|
||||||
// id, user_id, display_name, email, phone_number, role, brand_id,
|
* Read rc_auth_uid from the raw HTTP Cookie header. This is set by the
|
||||||
// can_manage_<X> (BOOLEAN each), active, must_change_password,
|
* Emergency Force Login flow and acts as a dev-mode bypass for normal auth.
|
||||||
// created_at, last_login, raw_user_meta_data, auth_provider, auth_subject
|
*/
|
||||||
|
async function readRcAuthUid(): Promise<string | null> {
|
||||||
|
const headerStore = await headers();
|
||||||
|
const cookieHeader = headerStore.get("cookie") || "";
|
||||||
|
const allCookies = cookieHeader.split(";").map(c => c.trim());
|
||||||
|
const rcUidCookie = allCookies.find(c => c.startsWith("rc_auth_uid="));
|
||||||
|
return rcUidCookie ? rcUidCookie.split("=")[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated RPC call. Validates the session via better-auth, then calls
|
||||||
|
* the named RPC using the service-role client.
|
||||||
|
*
|
||||||
|
* In development, the DEV_FORCE_UID cookie bypasses the auth check (the
|
||||||
|
* Emergency Force Login path is itself authenticated upstream).
|
||||||
|
*/
|
||||||
|
async function callRpcWithAuth<T>(fn: string, params: Record<string, unknown>): Promise<{ data: T | null; error: string | null }> {
|
||||||
|
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
||||||
|
const rcAuthUid = await readRcAuthUid();
|
||||||
|
if (rcAuthUid === DEV_FORCE_UID) {
|
||||||
|
return { data: null, error: null }; // dev force-login bypass
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerStore = await headers();
|
||||||
|
const session = await auth.api.getSession({ headers: headerStore });
|
||||||
|
if (!session?.user) {
|
||||||
|
return { data: null, error: "Not authenticated" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await svcRpc<T>(fn, params);
|
||||||
|
return { data: data as T, error: error ? error.message : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create auth user with the provided password (better-auth signUpEmail)
|
||||||
|
const { data: authUser, error: authError } = await authAdmin.createUser({
|
||||||
|
email: input.email,
|
||||||
|
password: input.password,
|
||||||
|
name: input.display_name || input.email.split("@")[0],
|
||||||
|
});
|
||||||
|
if (authError || !authUser) {
|
||||||
|
return { user: null, error: authError?.message ?? "Failed to create auth user" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into admin_users (service role bypasses RLS)
|
||||||
|
const { data: inserted, error: insertError } = await svcApi
|
||||||
|
.from("admin_users")
|
||||||
|
.insert({
|
||||||
|
user_id: authUser.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() as { data: any; error: { message: string } | null };
|
||||||
|
|
||||||
|
if (insertError) {
|
||||||
|
return { user: null, error: insertError.message };
|
||||||
|
}
|
||||||
|
if (!inserted) {
|
||||||
|
return { user: null, error: "Insert returned no row" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: (row.user_id as string | null) ?? null,
|
user_id: String(row.user_id ?? ""),
|
||||||
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,
|
||||||
@@ -106,529 +231,332 @@ function mapUserRow(row: Record<string, unknown>): AdminUserRow {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Welcome email (best-effort) ────────────────────────────────────────────
|
// ─── Dev path helpers (service role, local only) ───────────────────────────
|
||||||
|
|
||||||
async function sendWelcomeEmailSafe(input: {
|
async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
||||||
to: string;
|
// Ensure caller has an admin_users record
|
||||||
name: string;
|
if (callerUid) {
|
||||||
role: "platform_admin" | "brand_admin" | "store_employee";
|
const { data: existing } = await svcApi
|
||||||
password: string;
|
.from("admin_users")
|
||||||
brandId?: string;
|
.select("id")
|
||||||
}): Promise<{ sent: boolean; error?: string }> {
|
.eq("user_id", callerUid)
|
||||||
try {
|
.maybeSingle() as { data: any };
|
||||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
|
||||||
const { getBrandSettings } = await import("@/actions/brand-settings");
|
|
||||||
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
|
||||||
|
|
||||||
let logoUrl: string | null = null;
|
if (!existing) {
|
||||||
let brandName = "Route Commerce";
|
// auto-creating admin_users for uid
|
||||||
if (input.brandId) {
|
const { data: listData } = await authAdmin.listUsers({ searchValue: undefined });
|
||||||
try {
|
const authUser = (listData ?? []).find((u: any) => u.id === callerUid);
|
||||||
const settings = await getBrandSettings(input.brandId);
|
await svcApi.from("admin_users").insert({
|
||||||
if (settings.success && settings.settings) {
|
user_id: callerUid,
|
||||||
logoUrl = settings.settings.logo_url ?? null;
|
role: "platform_admin",
|
||||||
brandName = settings.settings.brand_name ?? brandName;
|
brand_id: null,
|
||||||
}
|
display_name: authUser?.name ?? authUser?.email?.split("@")[0] ?? "Admin",
|
||||||
} catch (brandErr) {
|
phone_number: null,
|
||||||
console.warn(
|
can_manage_products: true,
|
||||||
"[createAdminUser] Failed to load brand settings for welcome email:",
|
can_manage_stops: true,
|
||||||
brandErr,
|
can_manage_orders: true,
|
||||||
);
|
can_manage_pickup: true,
|
||||||
}
|
can_manage_messages: true,
|
||||||
}
|
can_manage_refunds: true,
|
||||||
|
can_manage_users: true,
|
||||||
const result = await sendWelcomeEmail({
|
can_manage_water_log: true,
|
||||||
to: input.to,
|
can_manage_reports: true,
|
||||||
name: input.name,
|
active: true,
|
||||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
must_change_password: false,
|
||||||
brandName,
|
|
||||||
tempPassword: input.password,
|
|
||||||
logoUrl: logoUrl ?? undefined,
|
|
||||||
});
|
});
|
||||||
return result.ok ? { sent: true } : { sent: false, error: result.error };
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
console.error("[createAdminUser] Welcome email failed (non-fatal):", msg);
|
|
||||||
return { sent: false, error: msg };
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all admin_users rows (no RLS for service role)
|
||||||
|
const { data: adminRows, error: adminError } = await svcApi
|
||||||
|
.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 }) as { data: any; error: any };
|
||||||
|
|
||||||
|
if (adminError) return { users: [], error: adminError.message };
|
||||||
|
|
||||||
|
// Fetch auth user details via better-auth admin list
|
||||||
|
const { data: listData, error: authError } = await authAdmin.listUsers({ searchValue: undefined });
|
||||||
|
if (authError) return { users: [], error: authError.message };
|
||||||
|
|
||||||
|
const authMap: Record<string, { email: string; display_name: string | null }> = {};
|
||||||
|
(listData ?? []).forEach((u: any) => {
|
||||||
|
authMap[u.id] = {
|
||||||
|
email: u.email ?? "",
|
||||||
|
display_name: u.name ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const users: AdminUserRow[] = (adminRows ?? []).map((row: any) => {
|
||||||
|
const r = row as Record<string, unknown> & { brands?: { name?: string } };
|
||||||
|
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null };
|
||||||
|
return {
|
||||||
|
...mapUserRow(r),
|
||||||
|
email: authInfo.email || "No Email",
|
||||||
|
display_name: authInfo.display_name ?? null,
|
||||||
|
phone_number: (r.phone_number as string | null) ?? null,
|
||||||
|
brand_name: r.brands?.name ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { users, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Public actions ─────────────────────────────────────────────────────────
|
// ─── 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 }> {
|
||||||
try {
|
const cookieStore = await cookies();
|
||||||
const sql = brandId
|
const headerStore = await headers();
|
||||||
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
const devSession = cookieStore.get("dev_session")?.value;
|
||||||
au.role, au.brand_id, b.name AS brand_name,
|
|
||||||
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
|
// Read rc_auth_uid for force-login check
|
||||||
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
|
const cookieHeader = headerStore.get("cookie") || "";
|
||||||
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
|
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||||
au.active, au.must_change_password, au.created_at, au.last_login
|
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||||
FROM admin_users au
|
|
||||||
LEFT JOIN brands b ON b.id = au.brand_id
|
// In development mode: ALL requests with a valid rc_auth_uid use the dev/service path.
|
||||||
WHERE au.brand_id = $1
|
// This includes real Supabase auth users — bypassing api.auth.getUser JWT validation.
|
||||||
ORDER BY au.created_at DESC`
|
if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) {
|
||||||
: `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
return devListAdminUsers(rcAuthUid);
|
||||||
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) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 better-auth session token in browser — use service role via devListAdminUsers
|
||||||
|
return devListAdminUsers(rcAuthUid);
|
||||||
|
}
|
||||||
|
return { users: result.data ?? [], error: result.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateAdminUserResult = {
|
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||||
user: AdminUserRow | null;
|
// Read auth context
|
||||||
error: string | null;
|
const cookieStore = await cookies();
|
||||||
/**
|
const headerStore = await headers();
|
||||||
* The temporary password that was set on the Neon Auth account. Only
|
const devSession = cookieStore.get("dev_session")?.value;
|
||||||
* populated on success. The UI should display this once to the caller
|
const cookieHeader = headerStore.get("cookie") || "";
|
||||||
* and offer a copy-to-clipboard affordance — the password is never
|
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||||
* stored in plaintext and cannot be retrieved again.
|
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||||
*/
|
|
||||||
tempPassword?: string;
|
|
||||||
/**
|
|
||||||
* Whether the welcome email was dispatched. Best-effort — the user is
|
|
||||||
* created even if email fails. False here means the caller should
|
|
||||||
* share the password out-of-band.
|
|
||||||
*/
|
|
||||||
emailSent?: boolean;
|
|
||||||
/**
|
|
||||||
* Set when `emailSent` is false. Helps the UI show a useful warning.
|
|
||||||
*/
|
|
||||||
emailError?: string;
|
|
||||||
/**
|
|
||||||
* Which Neon Auth path was used to create the account. "admin" means
|
|
||||||
* the caller's Neon Auth session has `role = 'admin'` and we used the
|
|
||||||
* privileged `auth.admin.createUser` endpoint. "signup" means we had
|
|
||||||
* to fall back to the public `/sign-up/email` endpoint (e.g. the
|
|
||||||
* caller's Neon Auth user is not flagged as admin, or the admin
|
|
||||||
* endpoint rejected the request). The choice is recorded for
|
|
||||||
* auditability.
|
|
||||||
*/
|
|
||||||
authPath?: "admin" | "signup";
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
// DEV_FORCE_UID bypass — set by Emergency Force Login or /api/force-admin
|
||||||
* Create a new admin user. Steps:
|
if (rcAuthUid === DEV_FORCE_UID) {
|
||||||
*
|
return devCreateAdminUser(input);
|
||||||
* 1. Verify the caller is a `platform_admin`.
|
|
||||||
* 2. Create the Neon Auth account (with the provided password) and
|
|
||||||
* link it to the local `admin_users` row via `user_id`. Tries the
|
|
||||||
* privileged `auth.admin.createUser` first; falls back to the
|
|
||||||
* public `/sign-up/email` + `emailVerified = true` pattern when
|
|
||||||
* the caller's Neon Auth session is not an admin (which is the
|
|
||||||
* common case in dev / when a brand admin's user was not
|
|
||||||
* provisioned as Neon Auth admin).
|
|
||||||
* 3. Insert `admin_user_brands` link if a brand was selected.
|
|
||||||
* 4. Send a welcome email with the temporary password (best-effort).
|
|
||||||
*
|
|
||||||
* The temp password is returned to the caller so the UI can show it
|
|
||||||
* once — this is the only opportunity to share it with the new user,
|
|
||||||
* since Neon Auth stores a hash, not the plaintext.
|
|
||||||
*/
|
|
||||||
export async function createAdminUser(
|
|
||||||
input: CreateAdminUserInput,
|
|
||||||
): Promise<CreateAdminUserResult> {
|
|
||||||
// 1. Authorization: only platform admins can mint new admin users.
|
|
||||||
const caller = await getAdminUser();
|
|
||||||
if (!caller) {
|
|
||||||
return { user: null, error: "Not authenticated. Please sign in again." };
|
|
||||||
}
|
|
||||||
if (caller.role !== "platform_admin") {
|
|
||||||
return {
|
|
||||||
user: null,
|
|
||||||
error: "Only platform admins can create new admin users.",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = input.email.trim().toLowerCase();
|
const isDevAdmin = process.env.NODE_ENV !== "production" && devSession === "platform_admin";
|
||||||
const password = input.password;
|
|
||||||
const displayName = input.display_name ?? email.split("@")[0];
|
|
||||||
const f = input.flags;
|
|
||||||
|
|
||||||
let neonAuthUserId: string;
|
// Dev path: use service role to create user without Supabase auth session
|
||||||
let authPath: "admin" | "signup" = "admin";
|
if (isDevAdmin) {
|
||||||
try {
|
return devCreateAdminUser(input);
|
||||||
// 2. Create the Neon Auth account. Try the privileged admin
|
}
|
||||||
// endpoint first.
|
|
||||||
const adminResult = await neonAuthCreateUser({
|
// Production path — use service role directly (bypasses Supabase JWT auth)
|
||||||
email,
|
// rc_auth_uid cookie proves the admin is logged in; service role creates the account
|
||||||
password,
|
if (rcAuthUid) {
|
||||||
name: displayName,
|
// Create auth user via better-auth
|
||||||
|
const { data: authUser, error: authError } = await authAdmin.createUser({
|
||||||
|
email: input.email,
|
||||||
|
password: input.password,
|
||||||
|
name: input.display_name || input.email.split("@")[0],
|
||||||
});
|
});
|
||||||
|
if (authError || !authUser) {
|
||||||
if (adminResult.data?.user?.id) {
|
return { user: null, error: authError?.message ?? "Failed to create auth user" };
|
||||||
neonAuthUserId = String(adminResult.data.user.id);
|
|
||||||
authPath = "admin";
|
|
||||||
} else if (adminResult.error) {
|
|
||||||
// 2b. Fallback: public sign-up + flip emailVerified to true. The
|
|
||||||
// admin endpoint requires the caller to have role='admin' in
|
|
||||||
// Neon Auth, which is not always true (provision-admin.ts does
|
|
||||||
// not set it). Falling back to sign-up is a safe, well-tested
|
|
||||||
// path — see scripts/seed-admin.ts.
|
|
||||||
const code = adminResult.error.code ?? "";
|
|
||||||
const isAuthRequired =
|
|
||||||
code === "FAILED_TO_CREATE_USER" ||
|
|
||||||
code === "FORBIDDEN" ||
|
|
||||||
code === "UNAUTHORIZED" ||
|
|
||||||
code === "INTERNAL_SERVER_ERROR" ||
|
|
||||||
code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL";
|
|
||||||
|
|
||||||
if (code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
|
||||||
return {
|
|
||||||
user: null,
|
|
||||||
error: `A Neon Auth user with email ${email} already exists. Pick a different email or reset the existing user's password.`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthRequired) {
|
// Insert into admin_users via service-role PostgREST
|
||||||
return {
|
const { data: inserted, error: insertError } = await svcApi
|
||||||
user: null,
|
.from("admin_users")
|
||||||
error: `Neon Auth createUser failed: ${adminResult.error.message ?? code ?? "unknown error"}`,
|
.insert({
|
||||||
};
|
user_id: authUser.id,
|
||||||
}
|
|
||||||
|
|
||||||
authPath = "signup";
|
|
||||||
const signupResult = await signupFallbackCreate(email, password, displayName);
|
|
||||||
if (!signupResult.userId) {
|
|
||||||
return {
|
|
||||||
user: null,
|
|
||||||
error: `Could not create Neon Auth user via fallback sign-up: ${signupResult.error}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
neonAuthUserId = signupResult.userId;
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
user: null,
|
|
||||||
error: "Neon Auth createUser returned neither data nor error.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
user: null,
|
|
||||||
error: `Neon Auth error: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Insert the local admin_users row + brand link in a transaction.
|
|
||||||
// We do this AFTER creating the Neon Auth user so a Neon Auth
|
|
||||||
// failure doesn't leave a dangling local row.
|
|
||||||
let insertedRow: Record<string, unknown> | null = null;
|
|
||||||
try {
|
|
||||||
const result = await withTx(async (client) => {
|
|
||||||
const ins = await client.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,'neon_auth',$17)
|
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
|
||||||
display_name = EXCLUDED.display_name,
|
|
||||||
email = EXCLUDED.email,
|
|
||||||
phone_number = EXCLUDED.phone_number,
|
|
||||||
role = EXCLUDED.role,
|
|
||||||
brand_id = EXCLUDED.brand_id,
|
|
||||||
can_manage_products = EXCLUDED.can_manage_products,
|
|
||||||
can_manage_stops = EXCLUDED.can_manage_stops,
|
|
||||||
can_manage_orders = EXCLUDED.can_manage_orders,
|
|
||||||
can_manage_pickup = EXCLUDED.can_manage_pickup,
|
|
||||||
can_manage_messages = EXCLUDED.can_manage_messages,
|
|
||||||
can_manage_refunds = EXCLUDED.can_manage_refunds,
|
|
||||||
can_manage_users = EXCLUDED.can_manage_users,
|
|
||||||
can_manage_water_log= EXCLUDED.can_manage_water_log,
|
|
||||||
can_manage_reports = EXCLUDED.can_manage_reports,
|
|
||||||
active = EXCLUDED.active,
|
|
||||||
must_change_password= EXCLUDED.must_change_password,
|
|
||||||
auth_provider = EXCLUDED.auth_provider
|
|
||||||
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`,
|
|
||||||
[
|
|
||||||
neonAuthUserId,
|
|
||||||
displayName,
|
|
||||||
email,
|
|
||||||
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,
|
|
||||||
neonAuthUserId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
if (!ins.rows[0]) throw new Error("admin_users insert returned no row");
|
|
||||||
|
|
||||||
if (input.brand_id) {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
ON CONFLICT (admin_user_id, brand_id) DO NOTHING`,
|
|
||||||
[ins.rows[0].id, input.brand_id],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return ins.rows[0];
|
|
||||||
});
|
|
||||||
insertedRow = result;
|
|
||||||
} catch (err) {
|
|
||||||
// We created the Neon Auth account but failed to insert the local
|
|
||||||
// row. Don't roll back the auth user — the operator can re-link
|
|
||||||
// it via the admin UI / DB. Surface the error.
|
|
||||||
console.error(
|
|
||||||
"[createAdminUser] Neon Auth user created (id=" + neonAuthUserId + ") but admin_users insert failed:",
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
user: null,
|
|
||||||
error:
|
|
||||||
`Neon Auth user was created but the local admin row failed: ${
|
|
||||||
err instanceof Error ? err.message : String(err)
|
|
||||||
}. The Neon Auth account is orphaned — link it manually via the admin_users table.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Welcome email (best-effort).
|
|
||||||
const emailResult = await sendWelcomeEmailSafe({
|
|
||||||
to: email,
|
|
||||||
name: displayName,
|
|
||||||
role: input.role,
|
role: input.role,
|
||||||
password,
|
brand_id: input.brand_id,
|
||||||
brandId: input.brand_id ?? undefined,
|
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();
|
||||||
|
|
||||||
return {
|
if (insertError || !inserted) {
|
||||||
user: mapUserRow(insertedRow),
|
return { user: null, error: insertError?.message ?? "Insert returned no row" };
|
||||||
error: null,
|
|
||||||
tempPassword: password,
|
|
||||||
emailSent: emailResult.sent,
|
|
||||||
emailError: emailResult.error,
|
|
||||||
authPath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public sign-up + emailVerified = true fallback. Used when the
|
|
||||||
* caller's Neon Auth session is not flagged as admin, or the admin
|
|
||||||
* endpoint rejected the request for any reason.
|
|
||||||
*/
|
|
||||||
async function signupFallbackCreate(
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
name: string,
|
|
||||||
): Promise<{ userId: string | null; error: string | null }> {
|
|
||||||
const baseUrl = process.env.NEON_AUTH_BASE_URL;
|
|
||||||
if (!baseUrl) {
|
|
||||||
return {
|
|
||||||
userId: null,
|
|
||||||
error: "NEON_AUTH_BASE_URL is not set; cannot fall back to public sign-up.",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send welcome email
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${baseUrl}/sign-up/email`, {
|
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||||
method: "POST",
|
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
||||||
headers: {
|
await sendWelcomeEmail({
|
||||||
"Content-Type": "application/json",
|
to: input.email,
|
||||||
"Origin": process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000",
|
name: input.display_name || input.email.split("@")[0],
|
||||||
"x-neon-auth-proxy": "node",
|
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 ?? authUser.id,
|
||||||
|
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
|
||||||
|
email: input.email,
|
||||||
|
phone_number: inserted.phone_number ?? input.phone_number ?? null,
|
||||||
|
role: (inserted.role as AdminUserRow["role"]) ?? "store_employee",
|
||||||
|
brand_id: inserted.brand_id ?? null,
|
||||||
|
brand_name: null,
|
||||||
|
can_manage_products: inserted.can_manage_products ?? false,
|
||||||
|
can_manage_stops: inserted.can_manage_stops ?? false,
|
||||||
|
can_manage_orders: inserted.can_manage_orders ?? false,
|
||||||
|
can_manage_pickup: inserted.can_manage_pickup ?? false,
|
||||||
|
can_manage_messages: inserted.can_manage_messages ?? false,
|
||||||
|
can_manage_refunds: inserted.can_manage_refunds ?? false,
|
||||||
|
can_manage_users: inserted.can_manage_users ?? false,
|
||||||
|
can_manage_water_log: inserted.can_manage_water_log ?? false,
|
||||||
|
can_manage_reports: inserted.can_manage_reports ?? false,
|
||||||
|
active: inserted.active ?? true,
|
||||||
|
must_change_password: inserted.must_change_password ?? true,
|
||||||
|
created_at: inserted.created_at ?? new Date().toISOString(),
|
||||||
|
last_login: null,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
error: null,
|
||||||
email,
|
|
||||||
password,
|
|
||||||
name,
|
|
||||||
callbackURL: "/admin",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = (await res.json().catch(() => ({}))) as {
|
|
||||||
user?: { id?: string };
|
|
||||||
code?: string;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
if (data.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
|
||||||
// Look up the existing user id so we can still link.
|
|
||||||
const existing = await query<{ id: string }>(
|
|
||||||
`SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1`,
|
|
||||||
[email],
|
|
||||||
);
|
|
||||||
if (existing.rows[0]?.id) {
|
|
||||||
return { userId: existing.rows[0].id, error: null };
|
|
||||||
}
|
|
||||||
return { userId: null, error: "User already exists but could not be looked up." };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
userId: null,
|
|
||||||
error: data.message ?? data.code ?? `Sign-up failed (HTTP ${res.status})`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = data.user?.id;
|
return { user: null, error: "Not authenticated" };
|
||||||
if (!userId) {
|
|
||||||
return { userId: null, error: "Sign-up response missing user.id" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flip emailVerified so the user can sign in without a verification step.
|
|
||||||
// The platform admin already vetted this email — they're handing the
|
|
||||||
// password to the user directly.
|
|
||||||
try {
|
|
||||||
await query(`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`, [userId]);
|
|
||||||
} catch (verifyErr) {
|
|
||||||
console.warn(
|
|
||||||
"[createAdminUser] Failed to set emailVerified=true (non-fatal):",
|
|
||||||
verifyErr,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { userId, error: null };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
userId: 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 }> {
|
||||||
try {
|
// Dev bypass check
|
||||||
// Build a partial SET clause. Each `can_manage_*` column is set
|
const cookieStore = await cookies();
|
||||||
// individually — the input's `flags` partial is spread across them.
|
const headerStore = await headers();
|
||||||
const sets: string[] = [];
|
const devSession = cookieStore.get("dev_session")?.value;
|
||||||
const params: unknown[] = [];
|
const cookieHeader = headerStore.get("cookie") || "";
|
||||||
const push = (col: string, val: unknown) => { params.push(val); sets.push(`${col} = $${params.length}`); };
|
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 { data, error } = await svcApi
|
||||||
|
.from("admin_users")
|
||||||
|
.update({
|
||||||
|
role: input.role ?? undefined,
|
||||||
|
brand_id: input.brand_id ?? undefined,
|
||||||
|
can_manage_products: input.flags?.can_manage_products ?? undefined,
|
||||||
|
can_manage_stops: input.flags?.can_manage_stops ?? undefined,
|
||||||
|
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 || !data) return { user: null, error: error?.message ?? "Update returned no row" };
|
||||||
|
return { user: mapUserRow(data as Record<string, unknown>), error: null };
|
||||||
|
}
|
||||||
|
|
||||||
if (input.role !== undefined) push("role", input.role);
|
const result = await callRpcWithAuth<AdminUserRow[]>("update_admin_user", {
|
||||||
if (input.brand_id !== undefined) push("brand_id", input.brand_id);
|
p_id: input.id,
|
||||||
if (input.active !== undefined) push("active", input.active);
|
p_role: input.role ?? null,
|
||||||
if (input.display_name !== undefined) push("display_name", input.display_name);
|
p_brand_id: input.brand_id ?? null,
|
||||||
if (input.phone_number !== undefined) push("phone_number", input.phone_number);
|
p_flags: input.flags ?? null,
|
||||||
if (input.flags) {
|
p_active: input.active ?? null,
|
||||||
for (const [key, val] of Object.entries(input.flags)) {
|
p_display_name: input.display_name ?? null,
|
||||||
if (val !== undefined) push(key, val);
|
p_phone_number: input.phone_number ?? null,
|
||||||
}
|
});
|
||||||
}
|
const rows = result.data as AdminUserRow[] | null;
|
||||||
if (sets.length === 0) return { user: null, error: "Nothing to update" };
|
return { user: rows?.[0] ?? null, error: result.error };
|
||||||
|
|
||||||
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 }> {
|
||||||
try {
|
// Dev bypass check
|
||||||
// No Supabase Auth — nothing to delete from the auth service.
|
const cookieStore = await cookies();
|
||||||
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
|
const headerStore = await headers();
|
||||||
return { success: (rowCount ?? 0) > 0, error: null };
|
const devSession = cookieStore.get("dev_session")?.value;
|
||||||
} catch (err) {
|
const cookieHeader = headerStore.get("cookie") || "";
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
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) {
|
||||||
|
// Get user_id first
|
||||||
|
const { data: adminRow, error: fetchError } = await svcApi
|
||||||
|
.from("admin_users")
|
||||||
|
.select("user_id")
|
||||||
|
.eq("id", id)
|
||||||
|
.single();
|
||||||
|
if (fetchError) return { success: false, error: fetchError.message };
|
||||||
|
// Delete from admin_users
|
||||||
|
const { error: deleteError } = await svcApi.from("admin_users").delete().eq("id", id);
|
||||||
|
if (deleteError) return { success: false, error: deleteError.message };
|
||||||
|
// Delete auth user via better-auth admin
|
||||||
|
if (adminRow?.user_id) {
|
||||||
|
await authAdmin.removeUser(adminRow.user_id);
|
||||||
}
|
}
|
||||||
|
return { success: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await callRpcWithAuth<boolean>("delete_admin_user", { p_id: id });
|
||||||
|
return { success: result.data ?? false, error: result.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
|
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
|
||||||
try {
|
const cookieStore = await cookies();
|
||||||
const { rowCount } = await query(
|
const headerStore = await headers();
|
||||||
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
|
const cookieHeader = headerStore.get("cookie") || "";
|
||||||
[userId],
|
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||||
);
|
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||||
return { success: (rowCount ?? 0) > 0, error: null };
|
|
||||||
} catch (err) {
|
if (rcAuthUid === DEV_FORCE_UID || process.env.NODE_ENV !== "production") {
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
const { error } = await svcApi.from("admin_users").update({ must_change_password: true }).eq("id", userId);
|
||||||
|
return { success: !error, error: error?.message ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Production path — use service role via direct update
|
||||||
|
const { error } = await svcApi.from("admin_users").update({ must_change_password: true }).eq("id", userId);
|
||||||
|
return { success: !error, error: error?.message ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function sendPasswordResetEmail(email: string): Promise<{ success: boolean; error: string | null }> {
|
||||||
* Sends a password-reset email to the user via Neon Auth.
|
const { error } = await authAdmin.requestPasswordReset({
|
||||||
*
|
email,
|
||||||
* Authorization: platform_admin only. Brand-scoped admins cannot
|
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`,
|
||||||
* trigger password resets for users outside their brand.
|
|
||||||
*
|
|
||||||
* The endpoint used (`requestPasswordReset`) is a public Neon Auth
|
|
||||||
* endpoint — it does not require the caller to be the target user.
|
|
||||||
* For the same reason the public /api/auth/forgot-password route
|
|
||||||
* works, this will always succeed against the Neon Auth API; we
|
|
||||||
* still return the result so the UI can show a clear success/failure
|
|
||||||
* message.
|
|
||||||
*/
|
|
||||||
export async function sendPasswordResetEmail(
|
|
||||||
email: string,
|
|
||||||
): Promise<{ success: boolean; error: string | null }> {
|
|
||||||
try {
|
|
||||||
// Authz: must be signed in as a platform_admin.
|
|
||||||
const me = await getAdminUser();
|
|
||||||
if (!me) {
|
|
||||||
return { success: false, error: "Not authenticated." };
|
|
||||||
}
|
|
||||||
if (me.role !== "platform_admin") {
|
|
||||||
return { success: false, error: "Only platform admins can send password resets." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedEmail = email.trim().toLowerCase();
|
|
||||||
if (!trimmedEmail) {
|
|
||||||
return { success: false, error: "Email is required." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
|
|
||||||
|
|
||||||
const result = await neonAuthRequestPasswordReset({
|
|
||||||
email: trimmedEmail,
|
|
||||||
redirectTo: `${siteUrl}/reset-password`,
|
|
||||||
});
|
});
|
||||||
|
return { success: !error, error: error?.message ?? null };
|
||||||
if (result?.error) {
|
|
||||||
console.error("[admin/sendPasswordResetEmail] Neon Auth error:", result.error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: result.error.message ?? result.error.code ?? "Failed to send reset email",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, error: null };
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[admin/sendPasswordResetEmail] Unexpected error:", err);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }> {
|
||||||
try {
|
const { data, error } = await publicApi.from("brands").select("id, name").order("name");
|
||||||
// Sort by name so the platform admin's brand picker stays stable.
|
return { brands: data ?? [], error: error?.message ?? null };
|
||||||
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) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"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";
|
||||||
@@ -42,9 +41,7 @@ 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" };
|
||||||
try {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||||
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,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { supabase } from "@/lib/supabase";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
export type AIAuthConfig = {
|
export type AIAuthConfig = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -18,7 +18,7 @@ export async function getAIPreferences(brandId: string): Promise<{
|
|||||||
model?: string;
|
model?: string;
|
||||||
max_tokens?: number;
|
max_tokens?: number;
|
||||||
} | null> {
|
} | null> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await api
|
||||||
.from("brand_ai_settings")
|
.from("brand_ai_settings")
|
||||||
.select("api_key, organization_id, base_url, model, max_tokens")
|
.select("api_key, organization_id, base_url, model, max_tokens")
|
||||||
.eq("brand_id", brandId)
|
.eq("brand_id", brandId)
|
||||||
@@ -32,7 +32,7 @@ export async function saveAIPreferences(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
config: AIAuthConfig
|
config: AIAuthConfig
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const result = await supabase
|
const { error } = await api
|
||||||
.from("brand_ai_settings")
|
.from("brand_ai_settings")
|
||||||
.upsert({
|
.upsert({
|
||||||
brand_id: brandId,
|
brand_id: brandId,
|
||||||
@@ -43,9 +43,9 @@ export async function saveAIPreferences(
|
|||||||
model: config.model || "gpt-4o-mini",
|
model: config.model || "gpt-4o-mini",
|
||||||
max_tokens: config.max_tokens || 4000,
|
max_tokens: config.max_tokens || 4000,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
}) as { data: unknown; error: { message: string } | null };
|
});
|
||||||
|
|
||||||
if (result.error) return { success: false, error: result.error.message };
|
if (error) return { success: false, error: error.message };
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+149
-216
@@ -1,8 +1,10 @@
|
|||||||
"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 { pool } from "@/lib/db";
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -53,66 +55,67 @@ export type ConversionFunnel = {
|
|||||||
rate: number;
|
rate: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||||
//
|
|
||||||
// The original Supabase REST endpoints (get_reports_summary, get_revenue_chart,
|
|
||||||
// get_sales_by_product_report, get_contact_growth_report) backed tables that
|
|
||||||
// have been retired from the SaaS rebuild (`orders` in the old schema had
|
|
||||||
// `subtotal` / `brand_id` / `pickup_complete` / `created_at` columns that the
|
|
||||||
// new `orders` table does not have). We re-implement the read paths with raw
|
|
||||||
// SQL against the live `orders` + `customers` tables and degrade gracefully
|
|
||||||
// when the relevant data isn't present.
|
|
||||||
//
|
|
||||||
// `recent_orders` / `conversion_funnel` use the new `orders` schema directly
|
|
||||||
// (total_cents, placed_at, fulfillment, status, customer_id, brand_id).
|
|
||||||
|
|
||||||
/**
|
async function brandScopedFetch<T>(
|
||||||
* Aggregate KPIs over a date window. Replaces the
|
endpoint: string,
|
||||||
* `get_reports_summary` SECURITY DEFINER RPC from
|
options?: RequestInit & { params?: Record<string, string> }
|
||||||
* `supabase/migrations/031_reports_v1_rpcs.sql`. Returns zeroed metrics
|
): Promise<T> {
|
||||||
* if the caller is unauthenticated or no orders exist in the window.
|
const adminUser = await getAdminUser();
|
||||||
*/
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
async function getReportsSummary(
|
|
||||||
brandId: string | null,
|
// brandId is available for future use in filtering
|
||||||
startDate: string,
|
void adminUser.brand_id;
|
||||||
endDate: string
|
|
||||||
): Promise<{
|
let url = `${supabaseUrl}/rest/v1${endpoint}`;
|
||||||
gross_sales: number;
|
if (options?.params) {
|
||||||
total_orders: number;
|
const searchParams = new URLSearchParams(options.params);
|
||||||
avg_order_value: number;
|
url += `?${searchParams.toString()}`;
|
||||||
}> {
|
|
||||||
const params: unknown[] = [startDate, endDate];
|
|
||||||
let brandFilter = "";
|
|
||||||
if (brandId) {
|
|
||||||
brandFilter = `AND brand_id = $3::uuid`;
|
|
||||||
params.push(brandId);
|
|
||||||
}
|
}
|
||||||
const { rows } = await pool.query<{
|
|
||||||
gross_sales: number;
|
const response = await fetch(url, {
|
||||||
total_orders: number;
|
...options,
|
||||||
avg_order_value: number;
|
headers: {
|
||||||
}>(
|
...svcHeaders(supabaseKey),
|
||||||
`SELECT
|
...options?.headers,
|
||||||
COALESCE(SUM(total_cents), 0)::float / 100.0 AS gross_sales,
|
},
|
||||||
COUNT(*)::int AS total_orders,
|
});
|
||||||
COALESCE(ROUND(AVG(total_cents)::numeric, 2), 0)::float AS avg_order_value
|
|
||||||
FROM orders
|
if (!response.ok) {
|
||||||
WHERE placed_at::date BETWEEN $1 AND $2
|
const err = await response.text();
|
||||||
AND status <> 'canceled'
|
throw new Error(`Analytics fetch failed: ${err}`);
|
||||||
${brandFilter}`,
|
}
|
||||||
params
|
|
||||||
);
|
return response.json() as Promise<T>;
|
||||||
return rows[0] ?? { gross_sales: 0, total_orders: 0, avg_order_value: 0 };
|
}
|
||||||
|
|
||||||
|
async function brandScopedRPC<T>(
|
||||||
|
rpcName: string,
|
||||||
|
params: Record<string, unknown>
|
||||||
|
): Promise<T> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
|
||||||
|
const brandId = adminUser.brand_id ?? null;
|
||||||
|
|
||||||
|
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_brand_id: brandId, ...params }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.text();
|
||||||
|
throw new Error(`RPC ${rpcName} failed: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Analytics Actions ─────────────────────────────────────────────────────────
|
// ── Analytics Actions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getAnalyticsMetrics(periodDays: number = 30): Promise<AnalyticsMetrics> {
|
export async function getAnalyticsMetrics(periodDays: number = 30): Promise<AnalyticsMetrics> {
|
||||||
try {
|
try {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
|
||||||
|
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setDate(startDate.getDate() - periodDays);
|
startDate.setDate(startDate.getDate() - periodDays);
|
||||||
@@ -122,42 +125,51 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
|
|||||||
const prevStartDate = new Date(prevEndDate);
|
const prevStartDate = new Date(prevEndDate);
|
||||||
prevStartDate.setDate(prevStartDate.getDate() - periodDays);
|
prevStartDate.setDate(prevStartDate.getDate() - periodDays);
|
||||||
|
|
||||||
const [current, previous] = await Promise.all([
|
// Current period
|
||||||
getReportsSummary(brandId, startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]),
|
const current = await brandScopedRPC<{
|
||||||
getReportsSummary(brandId, prevStartDate.toISOString().split("T")[0], prevEndDate.toISOString().split("T")[0]),
|
gross_sales: number;
|
||||||
]);
|
total_orders: number;
|
||||||
|
avg_order_value: number;
|
||||||
|
}>("get_reports_summary", {
|
||||||
|
p_start_date: startDate.toISOString().split("T")[0],
|
||||||
|
p_end_date: endDate.toISOString().split("T")[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Previous period
|
||||||
|
const previous = await brandScopedRPC<{
|
||||||
|
gross_sales: number;
|
||||||
|
total_orders: number;
|
||||||
|
avg_order_value: number;
|
||||||
|
}>("get_reports_summary", {
|
||||||
|
p_start_date: prevStartDate.toISOString().split("T")[0],
|
||||||
|
p_end_date: prevEndDate.toISOString().split("T")[0],
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate changes
|
// Calculate changes
|
||||||
const calcChange = (cur: number, prev: number) => {
|
const calcChange = (current: number, previous: number) => {
|
||||||
if (prev === 0) return cur > 0 ? 100 : 0;
|
if (previous === 0) return current > 0 ? 100 : 0;
|
||||||
return Math.round(((cur - prev) / prev) * 100 * 10) / 10;
|
return Math.round(((current - previous) / previous) * 100 * 10) / 10;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Active customers (count of `customers` rows scoped to the brand).
|
// Get customer count
|
||||||
const customerParams: unknown[] = [];
|
const customers = await brandScopedFetch<{ count: number }[]>(
|
||||||
let customerFilter = "";
|
"/communication_contacts",
|
||||||
if (brandId) {
|
{ params: { select: "count", limit: "1" } }
|
||||||
customerFilter = "WHERE brand_id = $1::uuid";
|
|
||||||
customerParams.push(brandId);
|
|
||||||
}
|
|
||||||
const customerCountRes = await pool.query<{ count: string }>(
|
|
||||||
`SELECT COUNT(*)::text AS count FROM customers ${customerFilter}`,
|
|
||||||
customerParams
|
|
||||||
);
|
);
|
||||||
const active_customers = parseInt(customerCountRes.rows[0]?.count ?? "0", 10);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total_revenue: current.gross_sales,
|
total_revenue: current?.gross_sales ?? 0,
|
||||||
revenue_change: calcChange(current.gross_sales, previous.gross_sales),
|
revenue_change: calcChange(current?.gross_sales ?? 0, previous?.gross_sales ?? 0),
|
||||||
total_orders: current.total_orders,
|
total_orders: current?.total_orders ?? 0,
|
||||||
orders_change: calcChange(current.total_orders, previous.total_orders),
|
orders_change: calcChange(current?.total_orders ?? 0, previous?.total_orders ?? 0),
|
||||||
active_customers,
|
active_customers: Array.isArray(customers) ? customers[0]?.count ?? 0 : 0,
|
||||||
customers_change: 0,
|
customers_change: 0,
|
||||||
avg_order_value: current.avg_order_value,
|
avg_order_value: current?.avg_order_value ?? 0,
|
||||||
aov_change: calcChange(current.avg_order_value, previous.avg_order_value),
|
aov_change: calcChange(current?.avg_order_value ?? 0, previous?.avg_order_value ?? 0),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch analytics metrics:", error);
|
console.error("Failed to fetch analytics metrics:", error);
|
||||||
|
// Return zeros on error
|
||||||
return {
|
return {
|
||||||
total_revenue: 0,
|
total_revenue: 0,
|
||||||
revenue_change: 0,
|
revenue_change: 0,
|
||||||
@@ -173,35 +185,16 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
|
|||||||
|
|
||||||
export async function getRevenueChart(periodDays: number = 30): Promise<RevenueDataPoint[]> {
|
export async function getRevenueChart(periodDays: number = 30): Promise<RevenueDataPoint[]> {
|
||||||
try {
|
try {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
|
||||||
|
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setDate(startDate.getDate() - periodDays);
|
startDate.setDate(startDate.getDate() - periodDays);
|
||||||
|
|
||||||
const params: unknown[] = [startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]];
|
const data = await brandScopedRPC<RevenueDataPoint[]>("get_revenue_chart", {
|
||||||
let brandFilter = "";
|
p_start_date: startDate.toISOString().split("T")[0],
|
||||||
if (brandId) {
|
p_end_date: endDate.toISOString().split("T")[0],
|
||||||
brandFilter = "AND brand_id = $3::uuid";
|
});
|
||||||
params.push(brandId);
|
|
||||||
}
|
return data ?? [];
|
||||||
const { rows } = await pool.query<{ date: string; revenue: number; orders: number }>(
|
|
||||||
`SELECT
|
|
||||||
d::date::text AS date,
|
|
||||||
COALESCE(SUM(o.total_cents), 0)::float / 100.0 AS revenue,
|
|
||||||
COUNT(o.id)::int AS orders
|
|
||||||
FROM generate_series($1::date, $2::date, '1 day'::interval) d
|
|
||||||
LEFT JOIN orders o
|
|
||||||
ON o.placed_at::date = d::date
|
|
||||||
AND o.status <> 'canceled'
|
|
||||||
${brandFilter.replace("AND", "AND o.")}
|
|
||||||
GROUP BY d::date
|
|
||||||
ORDER BY d::date`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch revenue chart:", error);
|
console.error("Failed to fetch revenue chart:", error);
|
||||||
return [];
|
return [];
|
||||||
@@ -210,50 +203,23 @@ export async function getRevenueChart(periodDays: number = 30): Promise<RevenueD
|
|||||||
|
|
||||||
export async function getTopProducts(limit: number = 5): Promise<ProductPerformance[]> {
|
export async function getTopProducts(limit: number = 5): Promise<ProductPerformance[]> {
|
||||||
try {
|
try {
|
||||||
const adminUser = await getAdminUser();
|
const result = await brandScopedRPC<Array<{
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
|
||||||
|
|
||||||
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
|
||||||
const endDate = new Date().toISOString().split("T")[0];
|
|
||||||
const params: unknown[] = [startDate, endDate];
|
|
||||||
let brandFilter = "";
|
|
||||||
if (brandId) {
|
|
||||||
brandFilter = "AND o.brand_id = $3::uuid";
|
|
||||||
params.push(brandId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { rows } = await pool.query<{
|
|
||||||
product_id: string;
|
|
||||||
product_name: string;
|
product_name: string;
|
||||||
units_sold: number;
|
units_sold: number;
|
||||||
gross_revenue: number;
|
gross_revenue: number;
|
||||||
avg_price: number;
|
avg_price: number;
|
||||||
}>(
|
product_id: string;
|
||||||
`SELECT
|
}>>("get_sales_by_product_report", {
|
||||||
p.id AS product_id,
|
p_start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
||||||
p.name AS product_name,
|
p_end_date: new Date().toISOString().split("T")[0],
|
||||||
COALESCE(SUM(oi.quantity), 0)::int AS units_sold,
|
});
|
||||||
COALESCE(SUM(oi.price_cents * oi.quantity), 0)::float / 100.0 AS gross_revenue,
|
|
||||||
COALESCE(ROUND(AVG(oi.price_cents)::numeric, 2), 0)::float / 100.0 AS avg_price
|
|
||||||
FROM order_items oi
|
|
||||||
JOIN orders o ON o.id = oi.order_id
|
|
||||||
JOIN products p ON p.id = oi.product_id
|
|
||||||
WHERE o.placed_at::date BETWEEN $1 AND $2
|
|
||||||
AND o.status <> 'canceled'
|
|
||||||
${brandFilter}
|
|
||||||
GROUP BY p.id, p.name
|
|
||||||
ORDER BY gross_revenue DESC
|
|
||||||
LIMIT ${Math.max(1, limit)}`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return (result ?? []).slice(0, limit).map(item => ({
|
||||||
product_id: r.product_id ?? "",
|
product_id: item.product_id ?? "",
|
||||||
product_name: r.product_name ?? "Unknown Product",
|
product_name: item.product_name ?? "Unknown Product",
|
||||||
units_sold: r.units_sold ?? 0,
|
units_sold: item.units_sold ?? 0,
|
||||||
revenue: r.gross_revenue ?? 0,
|
revenue: item.gross_revenue ?? 0,
|
||||||
avg_price: r.avg_price ?? 0,
|
avg_price: item.avg_price ?? 0,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch top products:", error);
|
console.error("Failed to fetch top products:", error);
|
||||||
@@ -265,46 +231,22 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
|||||||
try {
|
try {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
|
||||||
|
|
||||||
const params: unknown[] = [];
|
const brandId = adminUser.brand_id ?? null;
|
||||||
let brandFilter = "";
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
select: "id,customer_name,subtotal,status,created_at,fulfillment",
|
||||||
|
order: "created_at.desc",
|
||||||
|
limit: limit.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
brandFilter = "WHERE brand_id = $1::uuid";
|
params.append("brand_id", "eq." + brandId);
|
||||||
params.push(brandId);
|
|
||||||
}
|
}
|
||||||
const cappedLimit = Math.max(1, Math.min(limit, 100));
|
|
||||||
const { rows } = await pool.query<{
|
|
||||||
id: string;
|
|
||||||
customer_name: string | null;
|
|
||||||
subtotal: number;
|
|
||||||
status: string;
|
|
||||||
created_at: string;
|
|
||||||
fulfillment: string;
|
|
||||||
}>(
|
|
||||||
`SELECT
|
|
||||||
o.id::text AS id,
|
|
||||||
TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
|
|
||||||
o.total_cents::float / 100.0 AS subtotal,
|
|
||||||
o.status,
|
|
||||||
o.placed_at::text AS created_at,
|
|
||||||
o.fulfillment
|
|
||||||
FROM orders o
|
|
||||||
LEFT JOIN customers c ON c.id = o.customer_id
|
|
||||||
${brandFilter}
|
|
||||||
ORDER BY o.placed_at DESC
|
|
||||||
LIMIT ${cappedLimit}`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.map((r) => ({
|
const data = await brandScopedFetch<RecentOrder[]>(`/orders?${params.toString()}`);
|
||||||
id: r.id,
|
|
||||||
customer_name: r.customer_name ?? "Unknown",
|
return data ?? [];
|
||||||
subtotal: r.subtotal,
|
|
||||||
status: r.status,
|
|
||||||
created_at: r.created_at,
|
|
||||||
fulfillment: r.fulfillment,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch recent orders:", error);
|
console.error("Failed to fetch recent orders:", error);
|
||||||
return [];
|
return [];
|
||||||
@@ -313,37 +255,26 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
|||||||
|
|
||||||
export async function getCustomerGrowth(): Promise<CustomerGrowth> {
|
export async function getCustomerGrowth(): Promise<CustomerGrowth> {
|
||||||
try {
|
try {
|
||||||
const adminUser = await getAdminUser();
|
const result = await brandScopedRPC<{
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
total: number;
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
new_contacts: number;
|
||||||
|
growth_rate: number;
|
||||||
|
}>("get_contact_growth_report", {
|
||||||
|
p_start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
||||||
|
p_end_date: new Date().toISOString().split("T")[0],
|
||||||
|
});
|
||||||
|
|
||||||
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
// Get total customers
|
||||||
const endDate = new Date().toISOString().split("T")[0];
|
const totalCustomers = await brandScopedFetch<{ count: number }[]>(
|
||||||
const params: unknown[] = [startDate, endDate];
|
"/communication_contacts",
|
||||||
let brandFilter = "";
|
{ params: { select: "count", limit: "1" } }
|
||||||
if (brandId) {
|
|
||||||
brandFilter = "AND brand_id = $3::uuid";
|
|
||||||
params.push(brandId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// New-this-month + total customers in one query
|
|
||||||
const { rows } = await pool.query<{ total: number; new_count: number }>(
|
|
||||||
`SELECT
|
|
||||||
COUNT(*)::int AS total,
|
|
||||||
COUNT(*) FILTER (WHERE created_at::date BETWEEN $1 AND $2)::int AS new_count
|
|
||||||
FROM customers
|
|
||||||
WHERE 1=1 ${brandFilter}`,
|
|
||||||
params
|
|
||||||
);
|
);
|
||||||
const total = rows[0]?.total ?? 0;
|
|
||||||
const newThisMonth = rows[0]?.new_count ?? 0;
|
|
||||||
const growthRate = total > 0 ? (newThisMonth / total) * 100 : 0;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total_customers: total,
|
total_customers: Array.isArray(totalCustomers) ? totalCustomers[0]?.count ?? 0 : 0,
|
||||||
new_this_month: newThisMonth,
|
new_this_month: result?.new_contacts ?? 0,
|
||||||
retention_rate: 85,
|
retention_rate: 85,
|
||||||
growth_rate: Math.round(growthRate * 10) / 10,
|
growth_rate: result?.growth_rate ?? 0,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch customer growth:", error);
|
console.error("Failed to fetch customer growth:", error);
|
||||||
@@ -357,23 +288,25 @@ export async function getCustomerGrowth(): Promise<CustomerGrowth> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
||||||
|
// Return a standardized funnel based on order data
|
||||||
try {
|
try {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
|
||||||
|
|
||||||
const params: unknown[] = [];
|
const brandId = adminUser.brand_id ?? null;
|
||||||
let brandFilter = "";
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
select: "id,status",
|
||||||
|
limit: "1000",
|
||||||
|
});
|
||||||
|
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
brandFilter = "WHERE brand_id = $1::uuid";
|
params.append("brand_id", "eq." + brandId);
|
||||||
params.push(brandId);
|
|
||||||
}
|
}
|
||||||
const { rows } = await pool.query<{ status: string }>(
|
|
||||||
`SELECT status FROM orders ${brandFilter} LIMIT 1000`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
const total = rows.length;
|
const orders = await brandScopedFetch<Array<{ status: string }>>(`/orders?${params.toString()}`);
|
||||||
|
|
||||||
|
const total = orders?.length ?? 0;
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
return [
|
return [
|
||||||
{ stage: "Visitors", count: 0, rate: 0 },
|
{ stage: "Visitors", count: 0, rate: 0 },
|
||||||
@@ -384,7 +317,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const purchased = rows.filter((o) => o.status !== "canceled").length;
|
const purchased = orders?.filter(o => o.status !== "cancelled").length ?? 0;
|
||||||
const checkout = Math.round(purchased * 1.5);
|
const checkout = Math.round(purchased * 1.5);
|
||||||
const addToCart = Math.round(checkout * 2.6);
|
const addToCart = Math.round(checkout * 2.6);
|
||||||
const productViews = Math.round(addToCart * 2.7);
|
const productViews = Math.round(addToCart * 2.7);
|
||||||
|
|||||||
+25
-17
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export type AuditAction = "INSERT" | "UPDATE" | "DELETE";
|
export type AuditAction = "INSERT" | "UPDATE" | "DELETE";
|
||||||
|
|
||||||
@@ -21,11 +21,15 @@ type AuditResult =
|
|||||||
/**
|
/**
|
||||||
* Logs an audit event to the audit_logs table.
|
* Logs an audit event to the audit_logs table.
|
||||||
*
|
*
|
||||||
* Resolves the admin user from the Auth.js session via getAdminUser().
|
* In dev mode (dev_session cookie), uses the dev user identity.
|
||||||
* Audit writes go through the `log_audit_event` SECURITY DEFINER
|
* In production (Supabase auth), resolves the admin user from admin_users.
|
||||||
* PL/pgSQL function via the shared pg pool — no Supabase REST hop.
|
*
|
||||||
|
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
|
||||||
*/
|
*/
|
||||||
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
|
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
|
||||||
|
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
|
|
||||||
const performed_by = adminUser?.user_id ?? null;
|
const performed_by = adminUser?.user_id ?? null;
|
||||||
@@ -47,22 +51,26 @@ export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult>
|
|||||||
brand_id: payload.brand_id ?? null,
|
brand_id: payload.brand_id ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const response = await fetch(
|
||||||
const { rows } = await pool.query<{ id?: string }>(
|
`${supabaseUrl}/rest/v1/rpc/log_audit_event`,
|
||||||
"SELECT * FROM log_audit_event($1::jsonb)",
|
{
|
||||||
[JSON.stringify(rpcPayload)],
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({ p_payload: rpcPayload }),
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const auditId = typeof rows[0] === "string"
|
|
||||||
? rows[0]
|
if (!response.ok) {
|
||||||
: rows[0]?.id;
|
const err = await response.json().catch(() => ({ message: "Unknown error" }));
|
||||||
|
return { success: false, error: err.message ?? "Failed to write audit log" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const auditId = typeof data === "string" ? data : data?.[0]?.id ?? data?.id;
|
||||||
|
|
||||||
if (!auditId) {
|
if (!auditId) {
|
||||||
return { success: false, error: "Audit log written but ID not returned" };
|
return { success: false, error: "Audit log written but ID not returned" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, audit_id: auditId };
|
return { success: true, audit_id: auditId };
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : "Failed to write audit log",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import "server-only";
|
|
||||||
import { signIn, signOut } from "@/lib/auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign out and clear the Neon Auth session cookie.
|
|
||||||
*/
|
|
||||||
export async function signOutAction(): Promise<void> {
|
|
||||||
console.log("[auth/sign-out] Signing out");
|
|
||||||
await signOut();
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kick off a Google OAuth sign-in. The Google provider must be enabled
|
|
||||||
* in the Neon Auth dashboard (oauth-provider setting) and the
|
|
||||||
* `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` env vars must be set
|
|
||||||
* in the runtime environment.
|
|
||||||
*
|
|
||||||
* Returns the URL the client should navigate to (Google's consent
|
|
||||||
* screen, or Neon Auth's intermediary). The client should do
|
|
||||||
* `window.location.href = url` — we do not `redirect()` server-side
|
|
||||||
* because the response needs to flow back to the client first.
|
|
||||||
*/
|
|
||||||
export async function signInWithGoogleAction(input: {
|
|
||||||
callbackURL?: string;
|
|
||||||
errorCallbackURL?: string;
|
|
||||||
}): Promise<{ url: string | null; error: string | null }> {
|
|
||||||
const callbackURL = input.callbackURL ?? "/admin";
|
|
||||||
const errorCallbackURL = input.errorCallbackURL ?? "/login?error=oauth";
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Neon Auth's better-auth client returns `{ data, error }`. For
|
|
||||||
// social sign-in, `data` is `{ redirect, url, token?, user? }`.
|
|
||||||
// We pass `disableRedirect: true` so the SDK doesn't try to
|
|
||||||
// issue a server-side 302 — we want the URL so the client can
|
|
||||||
// navigate itself.
|
|
||||||
const result = await signIn.social({
|
|
||||||
provider: "google",
|
|
||||||
callbackURL,
|
|
||||||
errorCallbackURL,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
console.error("[auth/google] signIn.social error:", result.error);
|
|
||||||
return {
|
|
||||||
url: null,
|
|
||||||
error:
|
|
||||||
result.error.message ??
|
|
||||||
result.error.code ??
|
|
||||||
"Could not start Google sign-in.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = result.data?.url ?? null;
|
|
||||||
if (!url) {
|
|
||||||
return {
|
|
||||||
url: null,
|
|
||||||
error: "Google sign-in returned no redirect URL.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { url, error: null };
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[auth/google] Unexpected error:", err);
|
|
||||||
return {
|
|
||||||
url: null,
|
|
||||||
error: err instanceof Error ? err.message : "An unexpected error occurred.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,12 +36,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
import { withBrand } from "@/db/client";
|
|
||||||
import { products } from "@/db/schema";
|
|
||||||
import { and, eq, count } from "drizzle-orm";
|
|
||||||
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
|
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||||
|
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
|
||||||
|
|
||||||
export type BillingSubscriptionStatus =
|
export type BillingSubscriptionStatus =
|
||||||
| "active"
|
| "active"
|
||||||
| "trialing"
|
| "trialing"
|
||||||
@@ -97,76 +97,48 @@ export async function getBillingOverview(
|
|||||||
if (!brandId) return { success: false, error: "brandId required" };
|
if (!brandId) return { success: false, error: "brandId required" };
|
||||||
|
|
||||||
// 1) Plan info (plan_tier + limits + usage via get_brand_plan_info)
|
// 1) Plan info (plan_tier + limits + usage via get_brand_plan_info)
|
||||||
// Replicate the RPC inline via raw SQL on tenants + plans.
|
const planRes = await fetch(
|
||||||
const planRes = await pool.query<{
|
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`,
|
||||||
plan_tier: string;
|
{
|
||||||
plan_name: string | null;
|
method: "POST",
|
||||||
max_users: number;
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
max_stops_monthly: number;
|
body: JSON.stringify({ p_brand_id: brandId }),
|
||||||
max_products: number;
|
}
|
||||||
usage: { users: number; stops_this_month: number; products: number } | null;
|
|
||||||
}>(
|
|
||||||
`SELECT
|
|
||||||
t.plan_tier,
|
|
||||||
t.name AS plan_name,
|
|
||||||
COALESCE(t.max_users, 1) AS max_users,
|
|
||||||
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
|
|
||||||
COALESCE(t.max_products, 25) AS max_products,
|
|
||||||
jsonb_build_object(
|
|
||||||
'users', (SELECT count(*)::int FROM admin_user_brands tu WHERE tu.brand_id = t.id),
|
|
||||||
'stops_this_month', (SELECT count(*)::int FROM stops s
|
|
||||||
WHERE s.brand_id = t.id
|
|
||||||
AND s.created_at >= date_trunc('month', now())),
|
|
||||||
'products', (SELECT count(*)::int FROM products p
|
|
||||||
WHERE p.brand_id = t.id AND p.active = true AND p.deleted_at IS NULL)
|
|
||||||
) AS usage
|
|
||||||
FROM brands t
|
|
||||||
WHERE t.id = $1`,
|
|
||||||
[brandId]
|
|
||||||
);
|
);
|
||||||
const planData = planRes.rows[0] ?? null;
|
const planData = planRes.ok ? await planRes.json() : null;
|
||||||
|
|
||||||
// 2) Brand row: subscription state, name, stripe_customer_id
|
// 2) Brand row: subscription state, name, stripe_customer_id
|
||||||
const brandRes = await pool.query<{
|
const brandRes = await fetch(
|
||||||
name: string;
|
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name,stripe_customer_id,stripe_subscription_id,stripe_subscription_status,stripe_current_period_end`,
|
||||||
stripe_customer_id: string | null;
|
{ headers: svcHeaders(supabaseKey) }
|
||||||
stripe_subscription_id: string | null;
|
|
||||||
stripe_subscription_status: string | null;
|
|
||||||
stripe_current_period_end: string | null;
|
|
||||||
}>(
|
|
||||||
`SELECT name, stripe_customer_id, NULL::text AS stripe_subscription_id,
|
|
||||||
NULL::text AS stripe_subscription_status, NULL::text AS stripe_current_period_end
|
|
||||||
FROM brands WHERE id = $1`,
|
|
||||||
[brandId]
|
|
||||||
);
|
);
|
||||||
const brand = brandRes.rows[0] ?? null;
|
const brandRows = brandRes.ok ? await brandRes.json() : [];
|
||||||
|
const brand = Array.isArray(brandRows) ? brandRows[0] : null;
|
||||||
|
|
||||||
// 3) Enabled add-ons (feature flags from brand_settings)
|
// 3) Enabled add-ons (feature flags)
|
||||||
const addonsRes = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
|
const addonsRes = await fetch(
|
||||||
`SELECT feature_flags FROM brand_settings WHERE brand_id = $1`,
|
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
|
||||||
[brandId]
|
{
|
||||||
);
|
method: "POST",
|
||||||
const flags = addonsRes.rows[0]?.feature_flags ?? {};
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
const enabledAddons: Record<string, boolean> = {};
|
body: JSON.stringify({ p_brand_id: brandId }),
|
||||||
for (const [k, v] of Object.entries(flags ?? {})) {
|
|
||||||
enabledAddons[k] = v === true || v === "true" || v === 1 || v === "1";
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
const enabledAddons = addonsRes.ok ? await addonsRes.json() : {};
|
||||||
|
|
||||||
// 4) Active product count — same semantics as the dashboard
|
// 4) Active product count — same semantics as the dashboard
|
||||||
// (active=true). Keeps the billing page in sync with /admin
|
// (active=true AND deleted_at IS NULL). Keeps the billing page in
|
||||||
// "Active Products" stat.
|
// sync with /admin "Active Products" stat.
|
||||||
const productCountRows = await withBrand(brandId, (db) =>
|
const productRes = await fetch(
|
||||||
db
|
`${supabaseUrl}/rest/v1/products?select=id&brand_id=eq.${brandId}&active=eq.true&deleted_at=is.null&limit=1000`,
|
||||||
.select({ value: count() })
|
{ headers: { ...svcHeaders(supabaseKey), Prefer: "count=exact" } }
|
||||||
.from(products)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(products.brandId, brandId),
|
|
||||||
eq(products.active, true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const activeProductCount = Number(productCountRows[0]?.value ?? 0);
|
const productCountHeader = productRes.headers.get("Content-Range");
|
||||||
|
let activeProductCount = 0;
|
||||||
|
if (productCountHeader && productCountHeader.includes("/")) {
|
||||||
|
const total = productCountHeader.split("/").pop();
|
||||||
|
activeProductCount = parseInt(total ?? "0", 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
// 5) Admin context (so we know if the user can manage / is platform)
|
// 5) Admin context (so we know if the user can manage / is platform)
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
@@ -174,7 +146,7 @@ export async function getBillingOverview(
|
|||||||
const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings;
|
const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings;
|
||||||
|
|
||||||
// ── Normalize plan data ──────────────────────────────────────────────────
|
// ── Normalize plan data ──────────────────────────────────────────────────
|
||||||
const planTier = ((planData?.plan_tier as PlanTierKey | undefined) ?? "starter");
|
const planTier = (planData?.plan_tier ?? "starter") as PlanTierKey;
|
||||||
const limits = {
|
const limits = {
|
||||||
max_users: planData?.max_users ?? 1,
|
max_users: planData?.max_users ?? 1,
|
||||||
max_stops_monthly: planData?.max_stops_monthly ?? 10,
|
max_stops_monthly: planData?.max_stops_monthly ?? 10,
|
||||||
@@ -237,7 +209,7 @@ export async function getBillingOverview(
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
brandId,
|
brandId,
|
||||||
brandName: brand?.name ?? planData?.plan_name ?? null,
|
brandName: brand?.name ?? planData?.brand_name ?? null,
|
||||||
planTier,
|
planTier,
|
||||||
planCycle,
|
planCycle,
|
||||||
planMonthlyPrice,
|
planMonthlyPrice,
|
||||||
@@ -248,7 +220,7 @@ export async function getBillingOverview(
|
|||||||
currentPeriodEnd: brand?.stripe_current_period_end ?? null,
|
currentPeriodEnd: brand?.stripe_current_period_end ?? null,
|
||||||
limits,
|
limits,
|
||||||
usage,
|
usage,
|
||||||
enabledAddons,
|
enabledAddons: (enabledAddons ?? {}) as Record<string, boolean>,
|
||||||
addons,
|
addons,
|
||||||
displayedInvoiceAmount,
|
displayedInvoiceAmount,
|
||||||
monthlyTotal,
|
monthlyTotal,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { pool } from "@/lib/db";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
type LineItem = {
|
type LineItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -9,9 +9,6 @@ type LineItem = {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stripe API version type - using const assertion for type safety
|
|
||||||
type StripeApiVersion = "2026-05-27.dahlia";
|
|
||||||
|
|
||||||
export async function createRetailStripeCheckoutSession(
|
export async function createRetailStripeCheckoutSession(
|
||||||
items: LineItem[],
|
items: LineItem[],
|
||||||
orderId: string,
|
orderId: string,
|
||||||
@@ -23,7 +20,7 @@ export async function createRetailStripeCheckoutSession(
|
|||||||
if (!stripeKey) return { success: false, error: "Stripe not configured on this server." };
|
if (!stripeKey) return { success: false, error: "Stripe not configured on this server." };
|
||||||
|
|
||||||
const Stripe = (await import("stripe")).default;
|
const Stripe = (await import("stripe")).default;
|
||||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||||
|
|
||||||
const lineItems = items.map((item) => ({
|
const lineItems = items.map((item) => ({
|
||||||
price_data: {
|
price_data: {
|
||||||
@@ -35,13 +32,16 @@ export async function createRetailStripeCheckoutSession(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Get brand name for Stripe metadata
|
// Get brand name for Stripe metadata
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||||
|
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
|
||||||
let brandName = "Route Commerce";
|
let brandName = "Route Commerce";
|
||||||
try {
|
try {
|
||||||
const brandRes = await pool.query<{ name: string }>(
|
const brandRes = await fetch(
|
||||||
"SELECT name FROM brands WHERE id = $1 LIMIT 1",
|
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
|
||||||
[brandId]
|
{ headers: { ...svcHeaders(supabaseKey) } }
|
||||||
);
|
);
|
||||||
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
|
const brands = await brandRes.json() as Array<{ name: string }>;
|
||||||
|
if (brands?.[0]?.name) brandName = brands[0].name;
|
||||||
} catch {
|
} catch {
|
||||||
// use default
|
// use default
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { pool } from "@/lib/db";
|
|
||||||
|
|
||||||
// Stripe API version type
|
|
||||||
type StripeApiVersion = "2026-05-27.dahlia";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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-05-27.dahlia" as StripeApiVersion });
|
|
||||||
|
|
||||||
// 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
|
|
||||||
let brandName = "Route Commerce";
|
|
||||||
if (brandId) {
|
|
||||||
try {
|
|
||||||
const brandRes = await pool.query<{ name: string }>(
|
|
||||||
"SELECT name FROM brands WHERE id = $1 LIMIT 1",
|
|
||||||
[brandId]
|
|
||||||
);
|
|
||||||
if (brandRes.rows[0]?.name) brandName = brandRes.rows[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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
// Stripe API version type - using const assertion for type safety
|
|
||||||
type StripeApiVersion = "2026-05-27.dahlia";
|
|
||||||
|
|
||||||
// ── Price ID config ────────────────────────────────────────────────────────────
|
// ── Price ID config ────────────────────────────────────────────────────────────
|
||||||
// Maps plan/addon keys to Stripe price IDs via environment variables
|
// Maps plan/addon keys to Stripe price IDs via environment variables
|
||||||
@@ -46,18 +43,22 @@ export async function createStripeCheckoutSession(
|
|||||||
const priceId = getPriceId(priceKey);
|
const priceId = getPriceId(priceKey);
|
||||||
if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` };
|
if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` };
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||||
|
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
|
||||||
|
|
||||||
// Get brand's Stripe customer ID
|
// Get brand's Stripe customer ID
|
||||||
const custRes = await pool.query<{ stripe_customer_id: string | null; name: string }>(
|
const custRes = await fetch(
|
||||||
"SELECT stripe_customer_id, name FROM brands WHERE id = $1 LIMIT 1",
|
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id,name`,
|
||||||
[brandId]
|
{ headers: { ...svcHeaders(supabaseKey) } }
|
||||||
);
|
);
|
||||||
const brand = custRes.rows[0];
|
const brands = await custRes.json() as Array<{ stripe_customer_id: string | null; name: string }>;
|
||||||
|
const brand = brands[0];
|
||||||
if (!brand?.stripe_customer_id) {
|
if (!brand?.stripe_customer_id) {
|
||||||
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
|
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const Stripe = (await import("stripe")).default;
|
const Stripe = (await import("stripe")).default;
|
||||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||||
|
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
@@ -122,21 +123,26 @@ export async function cancelAddonSubscription(
|
|||||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||||
if (!stripeKey) return { success: false, error: "Stripe not configured" };
|
if (!stripeKey) return { success: false, error: "Stripe not configured" };
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||||
|
const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
|
||||||
|
|
||||||
// Get active subscription for this brand
|
// Get active subscription for this brand
|
||||||
const subRes = await pool.query<{ stripe_subscription_id: string | null }>(
|
const subRes = await fetch(
|
||||||
"SELECT stripe_subscription_id FROM subscriptions WHERE brand_id = $1 LIMIT 1",
|
`${supabaseUrl}/rest/v1/rpc/get_brand_subscription`,
|
||||||
[brandId]
|
{
|
||||||
);
|
method: "POST",
|
||||||
if (subRes.rows.length === 0) {
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
return { success: false, error: "No active subscription found" };
|
body: JSON.stringify({ p_brand_id: brandId }),
|
||||||
}
|
}
|
||||||
const subData = subRes.rows[0];
|
);
|
||||||
|
if (!subRes.ok) return { success: false, error: "Failed to get subscription" };
|
||||||
|
const subData = await subRes.json() as { stripe_subscription_id?: string };
|
||||||
if (!subData?.stripe_subscription_id) {
|
if (!subData?.stripe_subscription_id) {
|
||||||
return { success: false, error: "No active subscription found" };
|
return { success: false, error: "No active subscription found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const Stripe = (await import("stripe")).default;
|
const Stripe = (await import("stripe")).default;
|
||||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||||
|
|
||||||
// Retrieve subscription and find the item for this add-on
|
// Retrieve subscription and find the item for this add-on
|
||||||
const subscription = await stripe.subscriptions.retrieve(subData.stripe_subscription_id);
|
const subscription = await stripe.subscriptions.retrieve(subData.stripe_subscription_id);
|
||||||
@@ -156,14 +162,14 @@ export async function cancelAddonSubscription(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Disable the feature flag locally
|
// Disable the feature flag locally
|
||||||
try {
|
await fetch(
|
||||||
await pool.query(
|
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
|
||||||
"SELECT set_brand_feature($1, $2, $3)",
|
{
|
||||||
[brandId, addonKey, false]
|
method: "POST",
|
||||||
);
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
} catch {
|
body: JSON.stringify({ p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false }),
|
||||||
// best-effort: webhook reconciliation will eventually fix the flag
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user