Compare commits
206 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95eab42f4b | |||
| b018f2be5b | |||
| 49b8e27219 | |||
| 68a749f7af | |||
| d69b892117 | |||
| bd1690037d | |||
| fcdff8bce5 | |||
| a706746250 | |||
| a2285baeb4 | |||
| 2cf811a66b | |||
| 2daa8fd4b6 | |||
| 9f3dc9b68e | |||
| 6452363989 | |||
| 09f18de652 | |||
| 81ab512a5b | |||
| c087202bb4 | |||
| 661c6e8a86 | |||
| 1fa8a78f82 | |||
| afd96b93e1 | |||
| d913ca194e | |||
| f4d5a56181 | |||
| 139445879e | |||
| 818bd4d47b | |||
| 8a912b1e62 | |||
| f7a02fe127 | |||
| 4c428fd9da | |||
| c67df118e2 | |||
| d9a45b0b33 | |||
| cf451f0580 | |||
| 493ca4047f | |||
| cbd6eda640 | |||
| 37998aad46 | |||
| 1577f6363b | |||
| 9b9643e2c7 | |||
| d629cfe3d5 | |||
| 827b9ef318 | |||
| 6fc641a8d4 | |||
| dd545c00d6 | |||
| 487fafc828 | |||
| 5560727cd8 | |||
| 804940ae78 | |||
| 3ac167799b | |||
| 987ddcc25d | |||
| 506b062917 | |||
| 9256d7ac38 | |||
| 4e059e6def | |||
| 3f2c99fbb2 | |||
| 837f7f83cb | |||
| 532511e1b5 | |||
| 6d2c90ae6b | |||
| ef565fbfb1 | |||
| 895defa453 | |||
| 9edbfc2e1b | |||
| 6075d22a84 | |||
| cc5c0e49be | |||
| d50ec4deda | |||
| d3b8e4f7cd | |||
| 6a9807a3be | |||
| 182ccf2c72 | |||
| 6ffd07c54e | |||
| 417379d1af | |||
| ead638d9ae | |||
| f00b327477 | |||
| 6545c2e0d4 | |||
| ddf82a0c66 | |||
| 7e079e0186 | |||
| eb37df347e | |||
| 7e665ea43e | |||
| 9d9bc5d257 | |||
| d75380eb9a | |||
| 11cd2fd01a | |||
| 52c8a71cd0 | |||
| e5db66e74a | |||
| 365609d518 | |||
| a6df4c2dd9 | |||
| 4b781d3c76 | |||
| 0a534222e8 | |||
| 3d4b98d703 | |||
| 4f22da65d8 | |||
| 9da9c8b6e0 | |||
| 5902d2b360 | |||
| 36e13ba7fa | |||
| 9fcc514045 | |||
| 7047e086d6 | |||
| 1e0e278451 | |||
| 467f7e63fd | |||
| 685a1269a4 | |||
| 6c0a282765 | |||
| 1a47bbea2f | |||
| 442c16d572 | |||
| 8e937344ff | |||
| 4ded68cec2 | |||
| 750efdd318 | |||
| 18fb44ed38 | |||
| bd2dadd9ee | |||
| c8fa2e8b52 | |||
| 4ebbc6dacf | |||
| 9458fd0506 | |||
| 83ad6536a3 | |||
| 244551ce70 | |||
| b1d4174721 | |||
| 001840ab05 | |||
| f0a703794a | |||
| 7d1e6f784b | |||
| b5f7252ac0 | |||
| 24cf9a7261 | |||
| 8428f3a490 | |||
| 77fb8fe7ee | |||
| eabc709076 | |||
| 4bd2ed0db2 | |||
| c1396096ad | |||
| a53516bfe6 | |||
| fb23c21ad9 | |||
| 0cf2ee7948 | |||
| 6c1c616c1c | |||
| 4909a78aca | |||
| c6501b3ecd | |||
| edf3989ef2 | |||
| 1d4300d505 | |||
| 0db1609c89 | |||
| 91ba7b5c5c | |||
| d312783f3a | |||
| 1af47698a1 | |||
| 03bd0fbf1f | |||
| e28ebf5664 | |||
| 653dce747b | |||
| b46e00fefd | |||
| ceb061addf | |||
| 16c8edf7e9 | |||
| 2db6c0149b | |||
| 908b40aa1f | |||
| 4253544109 | |||
| 044ac6cd32 | |||
| cb0c9c8545 | |||
| 0f9ca2f331 | |||
| 916ad39176 | |||
| bb464bda65 | |||
| 03cf2f446f | |||
| a8a3f5d2e3 | |||
| 856b5caa40 | |||
| 16685422b9 | |||
| ce8ce98dde | |||
| cbb9f23012 | |||
| e7de43e723 | |||
| 50201b00fe | |||
| 67abcaa2db | |||
| 3f323dd52a | |||
| 3ad2a48fc3 | |||
| 99a3d66636 | |||
| eb9621d238 | |||
| b8317a200e | |||
| 01198111ea | |||
| 4b02154188 | |||
| 6e71596daf | |||
| 4da7aae5ce | |||
| ccfd89be75 | |||
| 42f28b32a4 | |||
| 720ffe5262 | |||
| 7cd0603cfb | |||
| f96dcd01f2 | |||
| 3f731f7739 | |||
| 5654ebaecd | |||
| 1cecbce392 | |||
| b63d0415ab | |||
| e2e56252ec | |||
| 48ce5665b9 | |||
| 2d55791458 | |||
| 6c5ca6829f | |||
| 1e9f9c0414 | |||
| 53d995fc99 | |||
| e499139c74 | |||
| 7489da3da0 | |||
| 32396af193 | |||
| f36419be69 | |||
| 5477b3419f | |||
| 2f3be5426f | |||
| 2d837bc786 | |||
| bb6dbe37a4 | |||
| 3f4f46da7e | |||
| ec1506dc82 | |||
| 2b3fd214d8 | |||
| 858ca0d64d | |||
| 8a91494009 | |||
| b240b7b56e | |||
| fd9d6424d5 | |||
| 0b748adfaa | |||
| 69767eb250 | |||
| 8bca9e86b1 | |||
| 82d81b2f69 | |||
| 7ecb1f2fc0 | |||
| 015b1cf7b5 | |||
| 1b12a0a95d | |||
| b2aa53f274 | |||
| 66c6f45efc | |||
| 6c6b5d3053 | |||
| 73cc7d1dce | |||
| 4763884caf | |||
| bdcaf0f1da | |||
| df6f4181df | |||
| 63842a9efc | |||
| bd623020d5 | |||
| 9b51f5ae29 | |||
| bc29c70e8b | |||
| 1feba25ced | |||
| f26d43ac96 | |||
| 49a9900d15 |
@@ -0,0 +1,81 @@
|
||||
# ============================================================================
|
||||
# Route Commerce — Environment variables
|
||||
# ============================================================================
|
||||
# Copy to `.env.local` and fill in real values for local development.
|
||||
# Production: set these in your hosting dashboard (Vercel / Netlify / etc.).
|
||||
# ============================================================================
|
||||
|
||||
# ── App ─────────────────────────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:4000
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:4000
|
||||
|
||||
# ── Database (Neon Postgres, direct pg driver) ─────────────────────────────
|
||||
# Single connection string used by the pg Pool in src/lib/auth.ts and the
|
||||
# admin-permissions / data-service layer. Format:
|
||||
# postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce
|
||||
|
||||
# ── Neon Auth (Better Auth) ───────────────────────────────────────────────
|
||||
# Get these from: neonctl neon-auth status
|
||||
# Base URL: "Auth Base URL" field
|
||||
# Cookie secret: openssl rand -base64 32 (min 32 chars)
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_PRICE_STARTER=
|
||||
STRIPE_PRICE_FARM=
|
||||
STRIPE_PRICE_ENTERPRISE=
|
||||
STRIPE_PRICE_HARVEST_REACH=
|
||||
STRIPE_PRICE_WHOLESALE_PORTAL=
|
||||
STRIPE_PRICE_WATER_LOG=
|
||||
STRIPE_PRICE_AI_TOOLS=
|
||||
STRIPE_PRICE_SQUARE_SYNC=
|
||||
STRIPE_PRICE_SMS_CAMPAIGNS=
|
||||
|
||||
# ── Resend (transactional email) ────────────────────────────────────────────
|
||||
RESEND_API_KEY=
|
||||
RESEND_WEBHOOK_SECRET=
|
||||
|
||||
# ── AI providers ──────────────────────────────────────────────────────────
|
||||
OPENAI_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
GOOGLE_API_KEY=
|
||||
XAI_API_KEY=
|
||||
MINIMAX_API_KEY=
|
||||
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||
|
||||
# ── Square (optional) ─────────────────────────────────────────────────────
|
||||
SQUARE_APP_SECRET=
|
||||
SQUARE_ENVIRONMENT=sandbox
|
||||
|
||||
# ── 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.
|
||||
@@ -0,0 +1,202 @@
|
||||
name: Deploy to route.crispygoat.com
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install dependencies
|
||||
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
|
||||
env:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}
|
||||
NEON_AUTH_BASE_URL: ${{ secrets.NEON_AUTH_BASE_URL }}
|
||||
NEON_AUTH_COOKIE_SECRET: ${{ secrets.NEON_AUTH_COOKIE_SECRET }}
|
||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||
AUTH_URL: ${{ secrets.AUTH_URL }}
|
||||
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
||||
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_PRICE_STARTER: ${{ secrets.STRIPE_PRICE_STARTER }}
|
||||
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 }}
|
||||
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
||||
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_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}
|
||||
NEON_AUTH_BASE_URL: ${{ secrets.NEON_AUTH_BASE_URL }}
|
||||
NEON_AUTH_COOKIE_SECRET: ${{ secrets.NEON_AUTH_COOKIE_SECRET }}
|
||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||
AUTH_URL: ${{ secrets.AUTH_URL }}
|
||||
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
||||
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_PRICE_STARTER: ${{ secrets.STRIPE_PRICE_STARTER }}
|
||||
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 }}
|
||||
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
||||
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_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||
SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }}
|
||||
run: |
|
||||
set -e
|
||||
APP_DIR=/home/tyler/route-commerce
|
||||
|
||||
# Setup SSH key - write raw (no printf which can corrupt multi-line keys)
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SERVER_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
# Verify key was written correctly
|
||||
if ! grep -q "PRIVATE KEY" ~/.ssh/id_ed25519; then
|
||||
echo "ERROR: SSH key not found or malformed. Check SERVER_SSH_KEY secret."
|
||||
cat ~/.ssh/id_ed25519 || echo "File is empty"
|
||||
exit 1
|
||||
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"
|
||||
@@ -0,0 +1,34 @@
|
||||
name: PWA + Mobile A11y Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lighthouse:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Start server
|
||||
run: npm run start &
|
||||
env:
|
||||
PORT: 3000
|
||||
|
||||
- name: Wait for server
|
||||
run: sleep 10
|
||||
|
||||
- name: Run Lighthouse CI
|
||||
run: npx lhci autorun
|
||||
@@ -39,6 +39,13 @@ next-env.d.ts
|
||||
# Supabase
|
||||
supabase/.temp/
|
||||
|
||||
# Playwright test results (generated, not source)
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# IDE / local config
|
||||
.mcp.json
|
||||
.env*
|
||||
public/videos/tuxedo-hero.mp4
|
||||
.neon
|
||||
.worktrees/
|
||||
|
||||
@@ -2,11 +2,55 @@
|
||||
|
||||
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`).
|
||||
|
||||
### Gitea authentication (SSH)
|
||||
|
||||
This workspace has been using Gitea via SSH for all operations. The Gitea instance is self-hosted at `git.crispygoat.com` (web UI: `https://git.crispygoat.com`, API base: `https://git.crispygoat.com/api/v1`).
|
||||
|
||||
**SSH key:** `~/.ssh/id_ed25519_crispygoat` is the dedicated key registered with the Gitea instance (alias `dog` per the Gitea SSH banner). Add it to the agent before any git operation:
|
||||
|
||||
```bash
|
||||
eval "$(ssh-agent -s)"
|
||||
ssh-add ~/.ssh/id_ed25519_crispygoat
|
||||
```
|
||||
|
||||
The SSH config (`~/.ssh/config`) already aliases the host as `crispygoat` with `AddKeysToAgent yes`, so subsequent shells can just `ssh crispygoat` / `git push origin ...` without re-adding.
|
||||
|
||||
**Verified working commands:**
|
||||
- `git push origin <branch>` — push (this is the primary workflow)
|
||||
- `ssh -p 22 git@git.crispygoat.com` — auth check; Gitea returns "successfully authenticated" + "Gitea does not provide shell access"
|
||||
|
||||
**API token:** This environment does **not** have a Gitea personal access token in any known location (no `~/.config/gitea`, no `GITEA_ACCESS_TOKEN` env, no token in `~/.config/gh/hosts.yml`). Do not assume a token is available — auth attempts will return `{"message":"invalid username, password or token"}`.
|
||||
|
||||
**`tea` CLI / `gitea-mcp`:** Both are installed but require a token; `--ssh-agent-key` is recognized by `tea logins add` but the underlying SDK still falls back to requiring a token. Treat these as unavailable until a token is provisioned.
|
||||
|
||||
### Opening pull requests (the workflow that actually works)
|
||||
|
||||
Because no Gitea API token is available in this environment, the PR creation flow is:
|
||||
|
||||
1. Create a feature branch: `git checkout -b docs/<date>-<description>`
|
||||
2. Commit the changes (no need to commit unrelated files — leave the worktree dirty but `git add` only what you intend to ship)
|
||||
3. Push: `git push -u origin <branch>`
|
||||
4. The Gitea push hook prints a "Create a new pull request" URL on stderr, e.g. `https://git.crispygoat.com/tyler/route-commerce/pulls/new/<branch>` — open that URL in a browser to file the PR through the web UI.
|
||||
|
||||
If a future environment provides a Gitea token, the `tea pr create` and `curl POST /api/v1/repos/tyler/route-commerce/pulls` flows become available; the auth pattern is `Authorization: token <TOKEN>` on the REST API or `tea logins add -t <TOKEN> -u https://git.crispygoat.com` for the CLI.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.
|
||||
|
||||
Tech stack: Next.js 16 (App Router) · Supabase (auth + Postgres + RLS) · Stripe · Square · Resend (email) · Tailwind CSS v4
|
||||
Tech stack: Next.js 16 (App Router) · **Postgres** (direct) · **Neon Auth** (Better Auth) · 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 must connect to Postgres directly via the shared `pg` `Pool` from `src/lib/db.ts`. No Supabase JS client, REST gateway, or `*.supabase.co` calls anywhere in the codebase.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,14 +65,12 @@ npx tsc --noEmit # TypeScript check (no emit)
|
||||
npx playwright test # Run E2E tests (Playwright)
|
||||
```
|
||||
|
||||
> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL.
|
||||
> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
|
||||
> For direct PG mode: `pg` and `dotenv` are already in devDependencies.
|
||||
> If `get_brand_settings` migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
||||
> The migrate script is `scripts/migrate.js`. It reads `DATABASE_URL` (or `DATABASE_ADMIN_URL`) from `.env.local` via `dotenv` and applies any `db/migrations/*.sql` files not already recorded in `_migrations`. `pg` is already in devDependencies.
|
||||
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
||||
|
||||
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details.
|
||||
**Historical migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for the modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed). Cat `MEMORY.md` for details.
|
||||
|
||||
No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`).
|
||||
E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:4000` for local runs (the dev server binds to port `4000`), or pass `--config` with a local config.
|
||||
|
||||
---
|
||||
|
||||
@@ -36,28 +78,91 @@ No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
**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)
|
||||
**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.
|
||||
|
||||
`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.
|
||||
**Auth flow:**
|
||||
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/proxy.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/proxy.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.
|
||||
|
||||
#### 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
|
||||
|
||||
All database writes go through server actions in `src/actions/`. These:
|
||||
1. Call `getAdminUser()` to verify auth
|
||||
2. Check role/permission flags (`can_manage_orders`, etc.)
|
||||
3. Call Supabase REST APIs (not the Supabase client directly in server actions) to trigger SECURITY DEFINER RPCs
|
||||
3. Call Postgres RPCs via the `pg` driver
|
||||
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.
|
||||
|
||||
### SECURITY DEFINER RPCs + Brand Scoping
|
||||
### Database (Postgres, direct)
|
||||
|
||||
The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass RLS entirely. This means:
|
||||
The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver to call `SECURITY DEFINER` PL/pgSQL functions; Drizzle (on top of the same pool) handles typed reads and the per-request `app.current_brand_id` GUC. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
|
||||
|
||||
#### Connection
|
||||
|
||||
- `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var.
|
||||
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` as `pool` (proxy) and `getPool()` (lazy). Server actions and API routes import it and call `pool.query(...)` against RPC names. The Drizzle client lives at `db/client.ts` and shares the same pool.
|
||||
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — none exist anywhere in 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
|
||||
- Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it
|
||||
@@ -182,10 +287,19 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
|
||||
|
||||
### Communications Module ("Harvest Reach")
|
||||
|
||||
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`.
|
||||
The communications system (`/admin/communications`) uses a separate set of tables that have **no row-level policies** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. Scoping is enforced by RPC + app layer.
|
||||
|
||||
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
|
||||
|
||||
**Scheduled automations** (declared in `vercel.json`):
|
||||
- `POST /api/email-automation/abandoned-cart` — every 6h, fires abandoned-cart sequence emails
|
||||
- `POST /api/email-automation/welcome-sequence` — every 6h, fires welcome onboarding sequence
|
||||
- `POST /api/cron/send-scheduled` — daily 09:00, sends scheduled campaigns
|
||||
- `POST /api/wholesale/notifications/{send,dispatch,pickup-reminder}` — wholesale lifecycle
|
||||
- `POST /api/square/process-queue` — every 2 min, drains Square sync queue
|
||||
|
||||
These endpoints are also reachable via curl for manual triggering; the email-automation routes accept `Authorization: Bearer $CRON_SECRET`.
|
||||
|
||||
### Payments
|
||||
|
||||
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
|
||||
@@ -200,7 +314,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- All DB mutations use Supabase REST API (`fetch` to `${supabaseUrl}/rest/v1/rpc/...`) from server actions, NOT the Supabase JS client (avoids SSR cookie issues)
|
||||
- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports, REST fetches, or `*.supabase.co` calls anywhere in the codebase.
|
||||
- `gen_random_uuid()` used in migrations for primary keys
|
||||
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
|
||||
- Status enums stored as TEXT — no PostgreSQL ENUM type
|
||||
@@ -215,13 +329,15 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
||||
|
||||
| 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` |
|
||||
| Middleware (route protection) | `src/middleware.ts` |
|
||||
| Server actions | `src/actions/*.ts` (one file per domain) |
|
||||
| Middleware (route protection) | `src/proxy.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}/`) |
|
||||
| Admin pages | `src/app/admin/[module]/page.tsx` |
|
||||
| Admin client components | `src/components/admin/*.tsx` |
|
||||
| Migrations | `supabase/migrations/` |
|
||||
| Supabase client | `src/lib/supabase.ts` |
|
||||
| Migrations | `db/migrations/` |
|
||||
| Postgres pool / driver | `src/lib/db.ts` (TBD) |
|
||||
| Email templates | `src/lib/email-templates.ts` |
|
||||
| Date formatting | `src/lib/format-date.ts` |
|
||||
| Feature flags | `src/lib/feature-flags.ts` |
|
||||
@@ -238,7 +354,9 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
||||
## Gotchas
|
||||
|
||||
- **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone.
|
||||
- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have RLS disabled. All brand scoping must be enforced in server actions.
|
||||
- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have no row-level policies. All brand scoping must be enforced in server actions.
|
||||
- **Webhook event_id**: `log_communication_messages` never populates `event_id`, so the Resend webhook uses `customer_email + subject` lookup instead.
|
||||
- **Mixed fulfillment orders**: An order can have both pickup and ship items. `get_shipping_orders` RPC returns orders with at least one `fulfillment = 'ship'` item.
|
||||
- **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`.
|
||||
- **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.
|
||||
@@ -0,0 +1,125 @@
|
||||
# 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
|
||||
@@ -10,18 +10,9 @@ Copy `.env.example` → `.env.local` and fill in the values.
|
||||
|
||||
These are safe to commit and can be used in client bundles. Prefix with `NEXT_PUBLIC_`.
|
||||
|
||||
### `NEXT_PUBLIC_SUPABASE_URL`
|
||||
Your Supabase project URL.
|
||||
- **Where to get:** Supabase Dashboard → Project Settings → API → Project URL
|
||||
- **Example:** `https://abc123.supabase.co`
|
||||
|
||||
### `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
Supabase anonymous (anon) key — safe for client-side use.
|
||||
- **Where to get:** Supabase Dashboard → Project Settings → API → anon key
|
||||
|
||||
### `NEXT_PUBLIC_BASE_URL`
|
||||
The base URL of your deployment. Used for OAuth redirects and webhook URLs.
|
||||
- **Local:** `http://localhost:3000`
|
||||
- **Local:** `http://localhost:4000` (dev script binds to port 4000; override via `npm run dev -- -p <port>`)
|
||||
- **Production:** `https://yourdomain.com`
|
||||
|
||||
### `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
|
||||
@@ -40,12 +31,16 @@ Set to a brand slug to hide other brand routes in single-brand mode.
|
||||
|
||||
**Never prefix with `NEXT_PUBLIC_`**. These must only be accessed in Server Components, Server Actions, or API Routes.
|
||||
|
||||
### Supabase
|
||||
### Database
|
||||
|
||||
#### `SUPABASE_SERVICE_ROLE_KEY`
|
||||
Full service role key — grants DB admin access bypassing RLS.
|
||||
- **Where to get:** Supabase Dashboard → Project Settings → API → service_role key
|
||||
- **⚠️ Critical:** Never expose this to the client. Used only in server-side code.
|
||||
#### `DATABASE_URL`
|
||||
Full Postgres connection string. The app uses this exclusively (no Supabase, no JS client) for every server-side query — server actions and API routes import a shared `pg` `Pool` from `src/lib/db.ts` and call `SECURITY DEFINER` PL/pgSQL functions.
|
||||
- **Where to get:** Neon Dashboard → Project → Connection Details → Connection string (pooled)
|
||||
- **Example:** `postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/routecommerce?sslmode=require`
|
||||
- **Format:** `postgres://user:pass@host:port/dbname?sslmode=require`
|
||||
|
||||
#### `DATABASE_ADMIN_URL` (optional)
|
||||
Same connection string but with elevated perms (typically the direct, non-pooled Neon connection). Only used by `scripts/migrate.js` for DDL — fall back to `DATABASE_URL` if unset.
|
||||
|
||||
### Stripe
|
||||
|
||||
@@ -156,7 +151,7 @@ Controls whether to use Square sandbox or production.
|
||||
|
||||
| Variable | Local (`.env.local`) | Production (hosting dashboard) |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | `https://yourdomain.com` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:4000` (or whatever port the dev server is on) | `https://yourdomain.com` |
|
||||
| `STRIPE_SECRET_KEY` | `sk_test_...` | `sk_live_...` |
|
||||
| `STRIPE_PUBLISHABLE_KEY` | `pk_test_...` | `pk_live_...` |
|
||||
| `SQUARE_ENVIRONMENT` | `sandbox` | `production` |
|
||||
@@ -168,5 +163,50 @@ Controls whether to use Square sandbox or production.
|
||||
- **Wrong Stripe key type:** Using `sk_live_` in development or `sk_test_` in production will silently fail. Always match key type to environment.
|
||||
- **Stripe price IDs drift:** If you create new prices in Stripe Dashboard but forget to update `.env.local`, billing will fail. Keep them in sync.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Route Commerce Launch Checklist Summary
|
||||
|
||||
**Last updated:** 2026-06-25
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the complete Launch & Marketing Layer implementation for Route Commerce, a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.
|
||||
@@ -8,15 +10,15 @@ This document summarizes the complete Launch & Marketing Layer implementation fo
|
||||
|
||||
## ✅ Implementation Status
|
||||
|
||||
### 1. Supabase Foundation ✓
|
||||
### 1. Database & Auth Foundation ✓
|
||||
|
||||
| Component | Status | Location |
|
||||
|-----------|--------|----------|
|
||||
| Auth Flow | ✓ Complete | Existing dev_session + Supabase Auth |
|
||||
| Database Structure | ✓ Complete | Supabase migrations in place |
|
||||
| Role-Based Access | ✓ Complete | admin-permissions.ts system |
|
||||
| Protected Routes | ✓ Complete | middleware.ts |
|
||||
| Server Actions Pattern | ✓ Complete | src/actions/*.ts |
|
||||
| Auth Flow | ✓ Complete | Neon Auth (Better Auth) + dev_session for local |
|
||||
| Database Structure | ✓ Complete | `db/migrations/` applied via `pg` |
|
||||
| Role-Based Access | ✓ Complete | `admin-permissions.ts` system |
|
||||
| Protected Routes | ✓ Complete | `src/proxy.ts` (Next.js 16+ middleware convention) |
|
||||
| Server Actions Pattern | ✓ Complete | `src/actions/*.ts` |
|
||||
|
||||
### 2. Launch-Ready Features ✓
|
||||
|
||||
@@ -237,9 +239,9 @@ Track these metrics post-launch:
|
||||
|
||||
- **Documentation**: See CLAUDE.md for technical details
|
||||
- **Stripe Dashboard**: https://dashboard.stripe.com
|
||||
- **Supabase Dashboard**: https://app.supabase.com/project/wnzkhezyhnfzhkhiflrp
|
||||
- **Neon Console**: https://console.neon.tech
|
||||
- **Vercel Deployments**: https://vercel.com/dashboard
|
||||
|
||||
---
|
||||
|
||||
*Generated: January 2025*
|
||||
*Generated: January 2025 · Refreshed: 2026-06-25 (see `CLAUDE.md` for current architecture; deployment is via Vercel hooked to the Gitea `origin` remote — there is no GitHub `origin`)*
|
||||
@@ -2,49 +2,127 @@
|
||||
|
||||
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
|
||||
|
||||
**Last updated:** 2026-06-03 (during Supabase migration apply session)
|
||||
**Last updated:** 2026-06-25 (added current-state header; restored "Supabase → Postgres" pivot as the canonical entry point for new readers; documented the SSH-based Gitea workflow)
|
||||
|
||||
> **Current state (read first).** Auth is **Neon Auth (Better Auth)** — see `CLAUDE.md`. The "Auth.js v5 wiring complete — 2026-06-06" entry further down is **historical** (it describes work that was subsequently replaced by the Neon Auth migration; `next-auth` remains in `package.json` as a vestigial dep but is no longer imported from `src/`). The Supabase → Postgres pivot section below is the canonical cutover record. The "GitHub origin" branch notes are also historical — the only remote is the Gitea `origin` (see `CLAUDE.md` "Canonical Remote"). Middleware lives at `src/proxy.ts`, not `src/middleware.ts` (Next.js 16+ convention). Repo operations use **SSH** (`~/.ssh/id_ed25519_crispygoat`) — no Gitea API token is provisioned in this env, so `tea`/`gitea-mcp`/REST API are unavailable for write operations; PRs are opened by `git push` + clicking the URL the Gitea push hook prints.
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## Supabase CLI + Migrations Tooling
|
||||
## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
|
||||
|
||||
**Status: COMPLETE (2026-06).** All Supabase references removed from the codebase:
|
||||
- `supabase/` directory deleted (`config.toml`, `push-migrations.js`, `ADMIN_CREATE_STOP_FIX.sql`, and 137 archived migration files in `supabase/migrations/.archived/` are gone).
|
||||
- `@supabase/ssr` and `@supabase/supabase-js` removed from `package.json`.
|
||||
- `next.config.ts` no longer whitelists `*.supabase.co` for images; `vercel.json` CSP no longer allows `https://*.supabase.co` in `connect-src`.
|
||||
- `eslint.config.mjs` no longer ignores `supabase/**`.
|
||||
- All `src/`, `tests/`, and `db/` files are free of `@supabase/*` imports, `rest/v1/` calls, and `*.supabase.co` references.
|
||||
- Migrations are now applied by `scripts/migrate.js` (which uses `pg` + `DATABASE_URL`); the Supabase CLI branch is gone with the deleted script.
|
||||
|
||||
This section is kept as **historical record** of the cutover. The "Supabase CLI + Migrations Tooling" section further below is also historical.
|
||||
|
||||
### Original pivot summary (kept for context)
|
||||
The platform moved off Supabase entirely. We connect to **Postgres directly** (via `pg`), with **Neon Auth (Better Auth)** handling authentication. See `CLAUDE.md` for the current architecture.
|
||||
|
||||
### What changed at the time
|
||||
- **DB connection**: `DATABASE_URL` (with `DATABASE_ADMIN_URL` optional override for DDL) is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
|
||||
- **Migrations**: `scripts/migrate.js` is the canonical runner. It reads `DATABASE_URL` from `.env.local` via `dotenv`, tracks applied files in `_migrations`, and applies any new files in `db/migrations/` in lexical order inside a transaction.
|
||||
- **Code**: zero `@supabase/*` imports, zero `rest/v1/` REST fetches, zero Supabase JS client usage. One shared `pg` `Pool` in `src/lib/db.ts`; Drizzle on top of it at `db/client.ts` for typed reads + RLS-style brand scoping.
|
||||
- **Auth**: Neon Auth (Better Auth) is the production path. `dev_session` cookie is the local-only bypass.
|
||||
- **Storage**: object store migration (S3-compatible) is independent of the Supabase purge and is tracked separately.
|
||||
|
||||
### 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 row-level policies, app-layer scoping" model still holds but the policies are inert in the new world.
|
||||
|
||||
### Historical sections below
|
||||
The "Supabase CLI + Migrations Tooling" section further down describes the *previous* tooling. It is kept as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use `scripts/migrate.js` (or `npm run migrate`) 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; script and CLI are no longer in the repo)*
|
||||
|
||||
### Login + Link (done in this session)
|
||||
- User ran `supabase login`
|
||||
- We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
|
||||
- Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`.
|
||||
- Link state lives under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root.
|
||||
- This enables `supabase db query --linked`, `supabase migration list`, etc.
|
||||
- Link state lived under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root.
|
||||
- This enabled `supabase db query --linked`, `supabase migration list`, etc.
|
||||
|
||||
### Important Environment Note
|
||||
- Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace:
|
||||
- `getaddrinfo ENOTFOUND`
|
||||
- IPv6 "network is unreachable" in some cases
|
||||
- HTTPS / Supabase REST / Management API work fine.
|
||||
- Therefore the `push-migrations.js` **CLI path** (using linked project) is the only reliable way to apply migrations here.
|
||||
- Therefore the `push-migrations.js` **CLI path** (using linked project) was the only reliable way to apply migrations here.
|
||||
|
||||
### Updated Migration Script
|
||||
File: `supabase/push-migrations.js`
|
||||
### Migration Script (deleted 2026-06 — use `scripts/migrate.js` instead)
|
||||
File: `supabase/push-migrations.js` *(deleted)*
|
||||
|
||||
Key changes:
|
||||
- Detection logic now recognizes modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
|
||||
- `pushWithCli()` rewritten to apply files one-by-one using:
|
||||
Key behavior (historical):
|
||||
- Detection logic recognized modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
|
||||
- `pushWithCli()` applied files one-by-one using:
|
||||
```bash
|
||||
supabase db query --linked --file "supabase/migrations/NNN_foo.sql"
|
||||
```
|
||||
(Previously tried `db push --db-url ...` which used direct connection and failed here.)
|
||||
- Falls back to direct `pg` only if CLI path fails.
|
||||
- Header comments updated with current recommended workflow.
|
||||
- Fell back to direct `pg` only if CLI path failed.
|
||||
|
||||
**Recommended commands now:**
|
||||
**Historical commands (Supabase CLI path — both scripts are gone):**
|
||||
```bash
|
||||
# Supabase CLI path (legacy — script deleted, do not use)
|
||||
supabase login
|
||||
supabase link --project-ref wnzkhezyhnfzhkhiflrp
|
||||
node supabase/push-migrations.js 148 # or any prefix
|
||||
node supabase/push-migrations.js 148 # CLI path (script deleted)
|
||||
# or
|
||||
npm run migrate:one 148
|
||||
```
|
||||
|
||||
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
|
||||
```bash
|
||||
# Direct pg path (this was the future — now the only path, via scripts/migrate.js)
|
||||
DATABASE_URL=postgres://... npm run migrate:one 148
|
||||
```
|
||||
|
||||
`npm run migrate` (no arg) pushes every `*.sql` in `db/migrations/` in order (use with caution).
|
||||
|
||||
---
|
||||
|
||||
@@ -133,12 +211,13 @@ Verification queries (post-apply) confirmed:
|
||||
|
||||
---
|
||||
|
||||
## Current State / Gotchas
|
||||
## Current State / Gotchas (2026-06)
|
||||
|
||||
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. Only early ones (001/002) + many timestamped migrations from other activity are tracked.
|
||||
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project.
|
||||
- When adding **new** migrations in the future, prefer the standard `supabase migration new` flow if possible, but the custom `push-migrations.js` + numeric prefix style is still the established pattern here.
|
||||
- Storage policies, RLS, SECURITY DEFINER functions, and brand-scoped data are all over the place — test carefully after big applies.
|
||||
- Use `DATABASE_URL` + `pg` directly (via `scripts/migrate.js`). The Supabase CLI and `supabase/push-migrations.js` are no longer in the repo.
|
||||
- `npm run migrate` applies every pending `db/migrations/*.sql` in lexical order inside transactions; `_migrations` tracks which files are already applied.
|
||||
- Many migrations are intentionally written to be re-runnable (`CREATE OR REPLACE FUNCTION`, `CREATE TABLE IF NOT EXISTS`, guarded triggers). Re-pushing a prefix is safe.
|
||||
- When adding **new** migrations, use the established numeric prefix style (`NNN_descriptive_name.sql`) and place them in `db/migrations/`. No Supabase CLI command is involved.
|
||||
- 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 (plus the per-request `app.current_brand_id` GUC used by Drizzle).
|
||||
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
|
||||
|
||||
---
|
||||
@@ -146,6 +225,7 @@ Verification queries (post-apply) confirmed:
|
||||
## How to Use This Memory
|
||||
|
||||
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
|
||||
- **Read the Direction Pivot section first** — it supersedes the older Supabase-flavored instructions.
|
||||
- Update this file with new key facts, applied migrations, or new gotchas.
|
||||
- Feel free to add dated sections.
|
||||
|
||||
@@ -153,9 +233,9 @@ Verification queries (post-apply) confirmed:
|
||||
|
||||
## Unrelated / Other Changes in Tree (as of last git status)
|
||||
|
||||
(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, `supabase/ADMIN_CREATE_STOP_FIX.sql`, a pricing assessment doc, etc.)
|
||||
(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, a pricing assessment doc, etc.)
|
||||
|
||||
This MEMORY.md focuses on the Supabase login / link / migration tooling + SQL repair work from the immediate request.
|
||||
This MEMORY.md originally focused on the Supabase login / link / migration tooling + SQL repair work from the immediate request. The pivot section now records the completion of the Supabase → Postgres migration.
|
||||
|
||||
---
|
||||
|
||||
@@ -245,3 +325,274 @@ Follow-up pass on the original Codex review covering public site, buyer path, bi
|
||||
### Migration 203 — applied via Supabase CLI
|
||||
|
||||
`203_plan_usage_active_products.sql` updates `get_brand_plan_info` to count `products` where `active = true AND deleted_at IS NULL`, matching the dashboard's "Active Products" stat. The `NOTIFY pgrst, 'reload schema'` ensures PostgREST picks up the change without restart.
|
||||
|
||||
## Gitea build fix — 2026-06-06
|
||||
|
||||
Gitea runner (`https://git.crispygoat.com/tyler/route-commerce.git`, branch `main`) was failing `next build` with two errors:
|
||||
|
||||
1. **DYNAMIC_SERVER_USAGE** on `/admin/settings/square-sync` (and the whole admin tree): `getAdminUser()` reads `cookies()` via `next/headers`. The admin layout tried to prerender statically, so the first child page that hit cookies aborted the build.
|
||||
2. **Prerender ECONNREFUSED** on `/indian-river-direct/stops`: `getPublicStopsForBrand` / `getActiveStopsForSitemap` / `getBrandSettingsPublic` fetch `NEXT_PUBLIC_SUPABASE_URL` at build time. The Gitea runner passes a Supabase URL that resolves but is unreachable, so `fetch` throws `ECONNREFUSED` and the prerender aborts.
|
||||
|
||||
The earlier commit `2f3be54 fix(actions): skip Supabase fetch at build time when env vars unset` only added `if (!supabaseUrl || !supabaseKey) return [];` — but in CI the env vars **are** set, so the guard passed and the fetch was still attempted.
|
||||
|
||||
### Fixes applied
|
||||
|
||||
- `src/actions/stops.ts` — wrapped `getActiveStopsForSitemap` and `getPublicStopsForBrand` fetches in `try/catch` returning `[]` on error. Env-var guard kept as fast path.
|
||||
- `src/actions/brand-settings.ts` — wrapped `getBrandSettingsPublic` fetch in `try/catch` returning `{ success: false }` on error.
|
||||
- `src/app/admin/settings/square-sync/page.tsx` — added `export const dynamic = "force-dynamic";` (was missing).
|
||||
- `src/app/admin/layout.tsx` — added `export const dynamic = "force-dynamic";` so the entire admin tree opts out of static prerender (layout calls `getAdminUser()` which reads cookies).
|
||||
- `.gitea/workflows/deploy.yml` was simplified earlier in commit `2d837bc` to a thin wrapper around `deploy/deploy.sh`.
|
||||
|
||||
### Remote
|
||||
|
||||
- The crispygoat repo (`git@git.crispygoat.com:tyler/route-commerce.git`) and the GitHub `origin` repo are separate forks — `tyler/main` is the self-hosted Auth.js + Postgres branch, `origin/main` is the Supabase branch. Don't merge them; they share no deploy workflow.
|
||||
- Push targets `tyler/main` to trigger the Gitea build.
|
||||
|
||||
## 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,47 +1,38 @@
|
||||
# Route Commerce — Production Deployment Checklist
|
||||
|
||||
**Platform Version:** 1.6
|
||||
**Last Updated:** 2026-05-13
|
||||
**Environment:** Next.js 16 (App Router) · Supabase · Stripe · Square · Resend · FedEx
|
||||
**Platform Version:** 2.0
|
||||
**Last Updated:** 2026-06-25
|
||||
**Environment:** Next.js 16 (App Router) · Postgres (direct via `pg`) · Neon Auth (Better Auth) · Stripe · Square · Resend · FedEx
|
||||
|
||||
---
|
||||
|
||||
## 1. Migration Order
|
||||
|
||||
Apply migrations in number order. All numbered `001`–`092` are required.
|
||||
Apply migrations in number order. The full set lives in `db/migrations/`:
|
||||
|
||||
```bash
|
||||
# Push all migrations (sequential)
|
||||
npm run migrate:one 001
|
||||
npm run migrate:one 002
|
||||
# ... through ...
|
||||
npm run migrate:one 092
|
||||
|
||||
# Or push all at once via Supabase CLI
|
||||
supabase db push
|
||||
# Push all migrations (idempotent — `_migrations` table tracks what's applied)
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
**Key migrations (last 15):**
|
||||
The current migration set is consolidated (the Supabase-era 001–092 series was collapsed into `0001_init.sql` plus a handful of incremental files). If `db/migrations/` is empty after the Supabase → Postgres migration, see `MEMORY.md` "Direction Pivot" for the cutover notes.
|
||||
|
||||
**Current migrations in `db/migrations/`:**
|
||||
|
||||
| # | File | Purpose |
|
||||
|---|------|---------|
|
||||
| 078 | `wholesale_deposit_guard.sql` | Adds `amount`, `payment_method` to `wholesale_deposits` |
|
||||
| 079 | `resend_analytics.sql` | Adds Resend webhook endpoint + analytics tables |
|
||||
| 080 | `communication_segments.sql` | Adds `communication_segments` table |
|
||||
| 081 | `stop_blast_campaign.sql` | Adds `stop_blast_campaign` RPC |
|
||||
| 082 | `brand_name_in_campaign_email.sql` | Adds `brand_name` column to `communication_campaigns` |
|
||||
| 083 | `shipping_settings.sql` | Adds `shipping_settings` table |
|
||||
| 084 | `shipments.sql` | Adds `shipments` table for FedEx tracking |
|
||||
| 085 | `brand_settings.sql` | Adds `brand_settings` table (core) |
|
||||
| 086 | `brand_settings_email_integration.sql` | Adds email/webhook fields to `brand_settings` |
|
||||
| 087 | `brand_logos_bucket.sql` | Adds `brand-logos` storage bucket |
|
||||
| 088 | `brand_features.sql` | Adds `brand_features` table + RPCs for add-on system |
|
||||
| 089 | `ai_providers_custom_integrations.sql` | Adds AI provider credentials storage |
|
||||
| 090 | `storefront_settings.sql` | Adds storefront customization fields + `get_brand_settings_by_slug` RPC |
|
||||
| 091 | `091_brand_plan_tier.sql` | Adds `plan_tier`, limits, `stripe_customer_id`, `get_brand_plan_info` RPC |
|
||||
| 092 | `092_stripe_subscriptions.sql` | Adds `stripe_subscription_id/status/current_period_end`, `set_brand_subscription`, `get_brand_subscription` RPCs |
|
||||
| 0000 | `0000_qa_neon_auth_stub.sql` | QA-only stub for `neon_auth` schema (real provisioning via Neon Auth in prod) |
|
||||
| 0001 | `0001_init.sql` | Core schema: brands, products, orders, stops, customers, communication tables, water log, time tracking, RPCs, indexes, triggers. Fully re-runnable (`IF NOT EXISTS` + trigger guards). |
|
||||
| 0002 | `0002_admin_password.sql` | Adds `password_hash` column to `admin_users` |
|
||||
| 0003 | `0003_tour_rpc_functions.sql` | RPCs for tour/stop planning |
|
||||
| 0040 | `0040_command_center.sql` | Adds command-center dashboard tables |
|
||||
| 0041 | `0041_command_center_schema_fix.sql` | Schema fixup for command-center |
|
||||
| 0042 | `0042_drop_command_center.sql` | Drops command-center (rolled back) |
|
||||
| 0043 | `0043_admin_users_extra_columns.sql` | Adds `display_name`, `phone_number`, `brand_id`, `can_manage_pickup/messages/refunds/users`, `active`, `must_change_password`, `auth_provider`, `auth_subject`, `last_login` columns to `admin_users` |
|
||||
| 0090 | `0090_water_log_completion.sql` | Completes water-log tables/RPCs |
|
||||
| 0091 | `0091_dashboard_summary_rpc.sql` | `dashboard_summary` RPC for admin dashboard |
|
||||
|
||||
> **Note:** Migration 092 adds Stripe subscription tracking. Migration 091 adds plan tier billing. Apply both before deploying billing changes.
|
||||
> **Note:** The migration runner (`scripts/migrate.js`) tracks applied files in `_migrations` and skips already-applied files. `0001_init.sql` is fully re-runnable, so it is safe to re-apply on a DB that was initialized outside the runner. Stripe subscription tracking, plan-tier billing, brand settings, etc. are all part of `0001_init.sql` — they were originally separate Supabase-era migrations that were consolidated.
|
||||
|
||||
---
|
||||
|
||||
@@ -53,10 +44,11 @@ Copy `.env.local.example` to `.env.local` and fill in all values.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL (e.g., `https://xxx.supabase.co`) |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anon/public key (safe to expose in browser) |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | Yes | Supabase service role key — **never expose in browser**, server-side only |
|
||||
| `SUPABASE_PAT` | Yes | Supabase Personal Access Token for migrations CLI |
|
||||
| `DATABASE_URL` | Yes | Postgres connection string (e.g., `postgresql://user:pass@host:5432/dbname?sslmode=require`) |
|
||||
| `DATABASE_ADMIN_URL` | No | Direct (non-pooled) Postgres connection with elevated perms. Only used by `scripts/migrate.js` for DDL — falls back to `DATABASE_URL` if unset. |
|
||||
| `NEON_AUTH_BASE_URL` | Yes | Neon Auth (Better Auth) base URL |
|
||||
| `NEON_AUTH_COOKIE_SECRET` | Yes | Neon Auth session cookie signing secret (min 32 chars) |
|
||||
| `NEXT_PUBLIC_SITE_URL` | Yes | Public site URL used for auth redirects and Origin header |
|
||||
|
||||
### Payments
|
||||
|
||||
@@ -198,17 +190,13 @@ For scheduled tasks (stop reminders, campaign sends), add to `vercel.json` or co
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Stop reminder emails are triggered by a Supabase time-based trigger (`send_stop_reminder_email`). Verify the trigger is active:
|
||||
```sql
|
||||
SELECT * FROM pg_publication_tables WHERE tablename = 'stops';
|
||||
```
|
||||
If not, recreate: `ALTER PUBLICATION supabase_realtime PUBLISH INSERT, UPDATE ON TABLE stops;`
|
||||
**Note:** Stop reminder emails are fired by Vercel cron jobs (`/api/cron/send-stop-reminders`), not by a database trigger. Verify the cron is configured in `vercel.json` (or Vercel Dashboard → Project → Cron Jobs).
|
||||
|
||||
---
|
||||
|
||||
## 5. Feature Flag Enablement (Post-Deploy)
|
||||
|
||||
After deployment, enable add-ons per brand via the admin UI or Supabase SQL:
|
||||
After deployment, enable add-ons per brand via the admin UI or direct Postgres:
|
||||
|
||||
```sql
|
||||
-- Enable Harvest Reach for a brand
|
||||
@@ -302,13 +290,13 @@ Vercel keeps 10 recent deployments. For critical regressions, promote the previo
|
||||
|
||||
### Database Rollback
|
||||
```bash
|
||||
# Revert last migration via Supabase
|
||||
supabase db reset --backup-id <backup-before-migration>
|
||||
# Revert last migration via Postgres
|
||||
# (Restore from a `pg_dump` snapshot taken before the migration was applied)
|
||||
|
||||
# Or manually revert (example for migration 090)
|
||||
psql $DATABASE_URL -c "ALTER TABLE brand_settings DROP COLUMN IF EXISTS schedule_pdf_notes;"
|
||||
```
|
||||
> **Important:** Only use `supabase db reset` on staging. In production, manually revert individual migrations to avoid data loss.
|
||||
> **Important:** Always restore from a known-good backup. In production, manually revert individual migrations to avoid data loss.
|
||||
|
||||
### Environment Variable Rollback
|
||||
Environment variables are version-controlled in Vercel. Restore a previous version in **Vercel Dashboard → Project → Environment Variables → History**.
|
||||
@@ -318,16 +306,16 @@ Environment variables are version-controlled in Vercel. Restore a previous versi
|
||||
## 8. Known Limitations & Future Work
|
||||
|
||||
### Known Issues
|
||||
- **`dev_session` auth**: The `dev_session` cookie bypasses real Supabase auth. In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`.
|
||||
- **`dev_session` auth**: The `dev_session` cookie bypasses real Neon Auth (Better Auth). In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`.
|
||||
- **Water Log visible to all brand admins**: Currently Tuxedo-only by brand ID check. If another brand admin needs access, the check needs to be made configurable.
|
||||
- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have RLS disabled. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct Supabase queries.
|
||||
- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have no row-level policies. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct SQL from client-side code.
|
||||
- **`event_id` not populated in message logs**: `send_campaign` and `send_stop_blast` do not set `event_id`. The Resend webhook looks up logs by `customer_email + subject` instead. This is a known limitation — if Resend events are delayed, the lookup may fail to match.
|
||||
- **Square sync is one-directional**: Products import from Square → Route Commerce, but product edits in Route Commerce do not push back to Square.
|
||||
- **SMS opt-in defaults to `FALSE`**: `communication_contacts.sms_opt_in` defaults to `false`. Users must explicitly opt in for SMS campaigns.
|
||||
- **`brand_id: null` for platform admins**: `getAdminUser()` returns `brand_id: null` for `platform_admin` role. Always pass explicit `brandId` to server action functions.
|
||||
- **Square webhook signature mismatch**: The Square webhook handler uses `crypto.createHmac("sha256", signatureKey)` but Square sends the signature as base64-encoded. Verify signature encoding matches Square's documentation before enabling Square webhooks in production.
|
||||
- **Stripe webhook reliability**: Stripe webhook delivery is not guaranteed to be in-order or immediate. The webhook handler is idempotent — it can safely receive duplicate events. Consider implementing a webhook event log table to track processed event IDs for deduplication.
|
||||
- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct Supabase queries to these tables in client-side code.
|
||||
- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct SQL to these tables in client-side code.
|
||||
|
||||
### Future Work (Non-Blocking)
|
||||
- **Cursor-based pagination**: Replace offset-based pagination (`page * limit`) with cursor-based pagination for large datasets (orders, contacts)
|
||||
@@ -344,11 +332,9 @@ Environment variables are version-controlled in Vercel. Restore a previous versi
|
||||
|
||||
| Role | Contact |
|
||||
|------|---------|
|
||||
| Platform Admin | Set via Supabase Auth dashboard |
|
||||
| Platform Admin | First Neon Auth user becomes the platform admin via `provision-admin.ts` |
|
||||
| Kyle Martinez | Primary contact — Route Commerce owner |
|
||||
| Supabase Support | supabase.com/support |
|
||||
| Neon Support | neon.tech/support |
|
||||
| Vercel Support | vercel.com/support |
|
||||
|
||||
For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button.
|
||||
|
||||
For Supabase production incidents: **supabase.com/dashboard** → project → "Support" tab.
|
||||
For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button.
|
||||
@@ -15,16 +15,16 @@ npm run dev
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Clerk Authentication
|
||||
### 1. Neon Auth (Better Auth)
|
||||
|
||||
1. Sign up at [Clerk](https://clerk.com)
|
||||
2. Create a new application
|
||||
3. Copy keys to `.env.local`:
|
||||
1. Set up Neon Auth on your Neon project (`neonctl neon-auth init`)
|
||||
2. Copy keys to `.env.local`:
|
||||
```
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx
|
||||
CLERK_SECRET_KEY=sk_test_xxx
|
||||
NEON_AUTH_BASE_URL=https://xxx.neonauth.io
|
||||
NEON_AUTH_COOKIE_SECRET=<openssl rand -base64 32>
|
||||
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
|
||||
```
|
||||
4. Configure middleware in `src/proxy.ts`
|
||||
3. Configure middleware in `src/proxy.ts`
|
||||
|
||||
### 2. Stripe Payments
|
||||
|
||||
@@ -37,15 +37,15 @@ npm run dev
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
```
|
||||
|
||||
### 3. Supabase Database
|
||||
### 3. Database (Postgres / Neon)
|
||||
|
||||
1. Create project at [Supabase](https://supabase.com)
|
||||
The app talks to Postgres directly via `pg` (no Supabase, no REST gateway). Point `DATABASE_URL` at any reachable Postgres — Neon, Supabase (the underlying Postgres), RDS, etc. The Supabase JS client and PostgREST are not used.
|
||||
|
||||
1. Provision a Postgres database (Neon recommended — it also hosts Neon Auth)
|
||||
2. Run migrations: `npm run migrate`
|
||||
3. Update `.env.local` with:
|
||||
```
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx
|
||||
SUPABASE_SERVICE_ROLE_KEY=xxx
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
|
||||
```
|
||||
|
||||
### 4. Optional Services
|
||||
@@ -74,7 +74,7 @@ Ensure environment variables are set before deployment.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/proxy.ts` - Clerk middleware
|
||||
- `src/proxy.ts` - Neon Auth (Better Auth) middleware
|
||||
- `src/lib/stripe-billing.ts` - Stripe integration
|
||||
- `src/lib/analytics.ts` - PostHog analytics
|
||||
- `src/lib/sentry.ts` - Sentry error tracking
|
||||
@@ -82,7 +82,7 @@ Ensure environment variables are set before deployment.
|
||||
- `src/components/onboarding/OnboardingFlow.tsx` - User onboarding
|
||||
- `src/components/referral/ReferralSystem.tsx` - Referral tracking
|
||||
- `src/components/changelog/ChangelogFeed.tsx` - Product updates
|
||||
- `supabase/migrations/` - Database schema
|
||||
- `db/migrations/` - Database schema (applied via `scripts/migrate.js`)
|
||||
|
||||
## Scripts
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Route Commerce helps produce brands run their wholesale operations:
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 16 (App Router)
|
||||
- **Database:** Supabase (Postgres + Auth + RLS)
|
||||
- **Database:** Postgres (direct via `pg` + Drizzle) with Neon Auth (Better Auth)
|
||||
- **Payments:** Stripe + Square
|
||||
- **Email/SMS:** Resend
|
||||
- **Styling:** Tailwind CSS v4
|
||||
@@ -37,10 +37,8 @@ npm install
|
||||
Create a `.env.local` file with:
|
||||
|
||||
```bash
|
||||
# Supabase
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||
# Database (Postgres)
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
@@ -56,10 +54,9 @@ RESEND_API_KEY=re_...
|
||||
|
||||
### 3. Set up the database
|
||||
|
||||
Link your Supabase project, then push migrations:
|
||||
The migrations live in `db/migrations/` and are applied via the `pg` driver (no Supabase CLI):
|
||||
|
||||
```bash
|
||||
supabase link --project-ref <your-project-ref>
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
@@ -75,7 +72,7 @@ npm run migrate:one 83
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000). The dev server auto-runs `fix-agents.js` to patch Next.js App Router agent issues.
|
||||
Open [http://localhost:4000](http://localhost:4000). The dev server auto-runs `fix-agents.js` to patch Next.js App Router agent issues. (The dev script binds to `0.0.0.0:4000`; override with `npm run dev -- -p <port>` if you need a different port.)
|
||||
|
||||
### 5. Dev auth bypass
|
||||
|
||||
@@ -111,7 +108,7 @@ src/
|
||||
│ ├── admin/ # Admin-specific components
|
||||
│ └── storefront/ # Storefront components
|
||||
└── lib/ # Utilities, auth, feature flags, formatting
|
||||
supabase/
|
||||
db/
|
||||
└── migrations/ # SQL migrations (numbered sequentially)
|
||||
```
|
||||
|
||||
@@ -171,7 +168,7 @@ The admin dashboard lives at `/admin`:
|
||||
- **Communications** — Harvest Reach campaign manager
|
||||
- **Wholesale** — Wholesale portal settings
|
||||
- **Billing** — Plan and subscription management
|
||||
- **Water Log** — Irrigation tracking (add-on)
|
||||
- **Water Log** — Irrigation tracking (add-on) — see [docs/water-log.md](docs/water-log.md)
|
||||
- **Settings** — Brand settings, payments, apps
|
||||
|
||||
## Email Automations (Harvest Reach)
|
||||
@@ -253,7 +250,7 @@ CRON_SECRET=... # Bearer token to secure cron endpoints (generate
|
||||
|
||||
```bash
|
||||
# Run abandoned cart with verbose output
|
||||
curl -X POST http://localhost:3000/api/email-automation/abandoned-cart \
|
||||
curl -X POST http://localhost:4000/api/email-automation/abandoned-cart \
|
||||
-H "Authorization: Bearer local-dev-secret" \
|
||||
-H "Content-Type: application/json"
|
||||
|
||||
@@ -315,8 +312,8 @@ curl -X POST https://route-commerce-platform.vercel.app/api/email-automation/wel
|
||||
|
||||
## Notes
|
||||
|
||||
- All database writes go through **server actions** (`src/actions/`), not the Supabase JS client directly
|
||||
- Dev mode bypasses Supabase auth via `dev_session` cookie — never use this in production
|
||||
- All database writes go through **server actions** (`src/actions/`) over a shared `pg` `Pool` from `src/lib/db.ts` — no Supabase JS client, no REST gateway
|
||||
- Dev mode bypasses Neon Auth via `dev_session` cookie — never use this in production
|
||||
- Brand-scoped data is enforced at the application layer via `p_brand_id` parameters in SECURITY DEFINER RPCs
|
||||
- Display dates use `formatDate()` (MM/DD/YYYY) — never raw `toLocaleDateString()`
|
||||
- Migration files are numbered sequentially — never reuse numbers
|
||||
@@ -0,0 +1,215 @@
|
||||
# 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.
|
||||
|
||||
> **Note (2026-06-25):** Auth has since moved from Auth.js (NextAuth v5) to **Neon Auth (Better Auth)** — see `CLAUDE.md` for the current architecture. The Supabase purge described below is complete: `supabase/` directory deleted, `@supabase/*` deps removed from `package.json`, `next.config.ts` / `vercel.json` / `eslint.config.mjs` cleaned of Supabase hostnames and ignore patterns, and the remaining `*.supabase.co` references are limited to historical/educational comments in `MEMORY.md` and `docs/`.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
`scripts/migrate.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).
|
||||
|
||||
### Resolution (2026-06-25)
|
||||
|
||||
Follow-ups 6 and 7 (and the data-fetching migration of all 33 files) are
|
||||
complete. The codebase is now Supabase-free at the source-code level —
|
||||
`grep -rln '@supabase\|rest/v1' src/` returns zero matches. The only
|
||||
remaining mentions of "Supabase" are in this report, `MEMORY.md`, and a
|
||||
handful of historical `docs/superpowers/{plans,specs}/` files, all kept
|
||||
as historical record of the migration.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
```
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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 { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import * as schema from "./schema";
|
||||
import { getPool, withTx } from "@/lib/db";
|
||||
|
||||
type Schema = typeof schema;
|
||||
export type Db = NodePgDatabase<Schema>;
|
||||
|
||||
/**
|
||||
* The Drizzle layer reuses the shared `pg` `Pool` from `@/lib/db` so the
|
||||
* app connects to Postgres through a single pool. Connection settings
|
||||
* (`PG_POOL_MAX`, `PG_POOL_IDLE_MS`, `PG_POOL_CONN_TIMEOUT_MS`,
|
||||
* `DATABASE_URL`) live in one place. See `src/lib/db.ts` for the config.
|
||||
*
|
||||
* The `withBrand` and `withPlatformAdmin` helpers reuse `withTx` from
|
||||
* `@/lib/db` so the BEGIN/COMMIT/ROLLBACK handling has a single home.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 withTx(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 withTx(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);
|
||||
});
|
||||
}
|
||||
|
||||
export { schema };
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 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.
|
||||
@@ -0,0 +1,71 @@
|
||||
-- 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,6 +1,48 @@
|
||||
-- Migration 127: Platform command center RPCs (final applied version)
|
||||
-- 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.
|
||||
|
||||
-- Platform-wide metrics
|
||||
-- ── 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
|
||||
@@ -47,7 +89,7 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Recent activity feed (last 50 operational events across all brands)
|
||||
-- ── Recent activity feed (last 50 operational events across all brands) ──────
|
||||
CREATE OR REPLACE FUNCTION get_platform_activity_feed()
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
@@ -83,8 +125,7 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Per-brand health snapshot
|
||||
DROP FUNCTION IF EXISTS get_brand_health_snapshot();
|
||||
-- ── Per-brand health snapshot ────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION get_brand_health_snapshot()
|
||||
RETURNS TABLE (
|
||||
brand_id UUID,
|
||||
@@ -152,4 +193,4 @@ BEGIN
|
||||
FROM bm
|
||||
LEFT JOIN pc ON pc.pc_bid = bm.bid;
|
||||
END;
|
||||
$$;
|
||||
$$;
|
||||
@@ -0,0 +1,147 @@
|
||||
-- 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.
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,70 @@
|
||||
-- 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);
|
||||
@@ -0,0 +1,172 @@
|
||||
-- 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());
|
||||
@@ -0,0 +1,89 @@
|
||||
-- 0091_dashboard_summary_rpc.sql
|
||||
--
|
||||
-- Dashboard summary RPC for the mobile-first admin v2 dashboard
|
||||
-- (`/admin/v2`).
|
||||
--
|
||||
-- Returns a single JSONB document that powers the four stat cards +
|
||||
-- the "Today's stops" timeline + the 7-day mini-chart:
|
||||
--
|
||||
-- {
|
||||
-- "orders_today": <int>, -- count of orders placed today
|
||||
-- "revenue_today": <int cents>, -- sum of total_cents (excl. canceled)
|
||||
-- "pending_fulfillment": <int>, -- count of orders awaiting pickup/ship
|
||||
-- "stops_today": <int>, -- count of stops scheduled for today
|
||||
-- "orders_last_7_days": [{date, count}] -- 7-element array, oldest → today
|
||||
-- }
|
||||
--
|
||||
-- Schema notes (vs. the plan's draft):
|
||||
-- * `orders` uses `placed_at` (TIMESTAMPTZ) — not `created_at`
|
||||
-- * `orders.status` is the legacy enum: 'pending' | 'confirmed' |
|
||||
-- 'fulfilled' | 'canceled' (not the v2 vocabulary 'placed'/'ready')
|
||||
-- * `orders.total_cents` is INTEGER (NOT NULL DEFAULT 0 in 0001) — no
|
||||
-- column-add step is required
|
||||
-- * `stops` has a `date DATE` column (not `scheduled_at`); the legacy
|
||||
-- stops page (and v2 stops page) both key off `stops.date`
|
||||
--
|
||||
-- SECURITY DEFINER so the v2 server action can call it without depending
|
||||
-- on RLS; brand scoping is enforced inside the function via the
|
||||
-- `p_brand_id` parameter (NULL → platform_admin "all brands" scope).
|
||||
-- Re-runs are safe — this is `CREATE OR REPLACE FUNCTION`.
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_dashboard_summary(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
result JSONB;
|
||||
brand_filter TEXT;
|
||||
BEGIN
|
||||
-- The brand filter is the same text fragment for every SELECT in this
|
||||
-- function. NULL = "all brands" (platform_admin scope); non-NULL =
|
||||
-- scope to a single brand.
|
||||
IF p_brand_id IS NULL THEN
|
||||
brand_filter := '';
|
||||
ELSE
|
||||
brand_filter := format(' AND brand_id = %L', p_brand_id);
|
||||
END IF;
|
||||
|
||||
EXECUTE format(
|
||||
$q$
|
||||
SELECT jsonb_build_object(
|
||||
'orders_today', (
|
||||
SELECT COUNT(*) FROM orders
|
||||
WHERE placed_at::date = CURRENT_DATE %s
|
||||
),
|
||||
'revenue_today', (
|
||||
SELECT COALESCE(SUM(total_cents), 0)::bigint FROM orders
|
||||
WHERE placed_at::date = CURRENT_DATE
|
||||
AND status <> 'canceled' %s
|
||||
),
|
||||
'pending_fulfillment', (
|
||||
SELECT COUNT(*) FROM orders
|
||||
WHERE status IN ('pending', 'confirmed') %s
|
||||
),
|
||||
'stops_today', (
|
||||
SELECT COUNT(*) FROM stops
|
||||
WHERE "date" = CURRENT_DATE %s
|
||||
),
|
||||
'orders_last_7_days', (
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object('date', d::date, 'count', COALESCE(o.cnt, 0)) ORDER BY d), '[]'::jsonb)
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') d
|
||||
LEFT JOIN (
|
||||
SELECT placed_at::date AS day, COUNT(*) AS cnt
|
||||
FROM orders
|
||||
WHERE placed_at >= CURRENT_DATE - INTERVAL '7 days' %s
|
||||
GROUP BY 1
|
||||
) o ON o.day = d::date
|
||||
)
|
||||
)
|
||||
$q$,
|
||||
brand_filter, brand_filter, brand_filter, brand_filter, brand_filter
|
||||
) INTO result;
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION get_dashboard_summary(UUID) IS
|
||||
'Returns a single JSONB document with the four dashboard stat counts + 7-day order history. p_brand_id = NULL means platform_admin scope (all brands).';
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Shared enums for the SaaS schema. Mirrored in SQL as TEXT + CHECK.
|
||||
*
|
||||
* Usage:
|
||||
* import { tenantStatusEnum, type TenantStatus } from "@/db/schema/enums";
|
||||
* import { pgEnum } from "drizzle-orm/pg-core";
|
||||
*
|
||||
* export const tenantStatus = pgEnum("tenant_status", tenantStatusEnum);
|
||||
*/
|
||||
|
||||
export const tenantStatusEnum = [
|
||||
"trial",
|
||||
"active",
|
||||
"past_due",
|
||||
"suspended",
|
||||
"churned",
|
||||
] as const;
|
||||
export type TenantStatus = (typeof tenantStatusEnum)[number];
|
||||
|
||||
export const authProviderEnum = ["dev", "google", "email"] as const;
|
||||
export type AuthProvider = (typeof authProviderEnum)[number];
|
||||
|
||||
export const roleEnum = ["platform_admin", "brand_admin", "store_employee"] as const;
|
||||
export type Role = (typeof roleEnum)[number];
|
||||
|
||||
export const planCodeEnum = ["starter", "farm", "enterprise"] as const;
|
||||
export type PlanCode = (typeof planCodeEnum)[number];
|
||||
|
||||
export const addOnCodeEnum = [
|
||||
"wholesale_portal",
|
||||
"harvest_reach",
|
||||
"ai_tools",
|
||||
"water_log",
|
||||
"square_sync",
|
||||
"sms_campaigns",
|
||||
] as const;
|
||||
export type AddOnCode = (typeof addOnCodeEnum)[number];
|
||||
|
||||
export const subscriptionStatusEnum = [
|
||||
"trialing",
|
||||
"active",
|
||||
"past_due",
|
||||
"canceled",
|
||||
"incomplete",
|
||||
] as const;
|
||||
export type SubscriptionStatus = (typeof subscriptionStatusEnum)[number];
|
||||
|
||||
export const addOnStatusEnum = ["active", "canceled"] as const;
|
||||
export type AddOnStatus = (typeof addOnStatusEnum)[number];
|
||||
|
||||
export const stopStatusEnum = ["active", "paused", "closed"] as const;
|
||||
export type StopStatus = (typeof stopStatusEnum)[number];
|
||||
|
||||
export const orderStatusEnum = [
|
||||
"pending",
|
||||
"confirmed",
|
||||
"fulfilled",
|
||||
"canceled",
|
||||
] as const;
|
||||
export type OrderStatus = (typeof orderStatusEnum)[number];
|
||||
|
||||
export const fulfillmentEnum = ["pickup", "ship", "mixed"] as const;
|
||||
export type Fulfillment = (typeof fulfillmentEnum)[number];
|
||||
|
||||
export const itemFulfillmentEnum = ["pickup", "ship"] as const;
|
||||
export type ItemFulfillment = (typeof itemFulfillmentEnum)[number];
|
||||
|
||||
export const campaignStatusEnum = [
|
||||
"draft",
|
||||
"scheduled",
|
||||
"sending",
|
||||
"sent",
|
||||
"canceled",
|
||||
] as const;
|
||||
export type CampaignStatus = (typeof campaignStatusEnum)[number];
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Marketing: email templates + campaigns.
|
||||
* Source of truth: `db/migrations/0001_init.sql`.
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { campaignStatusEnum } from "./enums";
|
||||
import { 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;
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
async function debugAuth() {
|
||||
console.log('Launching browser...');
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// First, login
|
||||
console.log('Navigating to login page...');
|
||||
await page.goto('https://route-commerce-platform.vercel.app/login');
|
||||
|
||||
console.log('Filling login form...');
|
||||
await page.fill('#email', 'kylemart@gmail.com');
|
||||
await page.fill('#password', 'Test123456!');
|
||||
|
||||
console.log('Clicking sign in...');
|
||||
const response = await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for network to settle
|
||||
await page.waitForLoadState('networkidle').catch(() => {});
|
||||
|
||||
console.log('Current URL after wait:', page.url());
|
||||
|
||||
// Get any error messages
|
||||
const errorText = await page.$eval('[role="alert"]', el => el.textContent).catch(() => null);
|
||||
if (errorText) console.log('Error message:', errorText);
|
||||
|
||||
const pageContent = await page.content();
|
||||
if (pageContent.includes('Access Denied')) {
|
||||
console.log('*** ACCESS DENIED PAGE DETECTED ***');
|
||||
}
|
||||
|
||||
// Check cookies
|
||||
const cookies = await context.cookies();
|
||||
console.log('Cookies:', cookies.map(c => `${c.name}=${c.value.slice(0, 30)}...`));
|
||||
|
||||
// Try to visit debug-auth page
|
||||
console.log('Navigating to /admin/debug-auth...');
|
||||
try {
|
||||
const response = await page.goto('https://route-commerce-platform.vercel.app/admin/debug-auth', { timeout: 10000 });
|
||||
console.log('Response status:', response?.status());
|
||||
console.log('Response URL:', page.url());
|
||||
const content = await page.content();
|
||||
console.log('Page content (first 2000 chars):', content.slice(0, 2000));
|
||||
} catch (e) {
|
||||
console.log('Error:', e);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
debugAuth().catch(console.error);
|
||||
@@ -0,0 +1,52 @@
|
||||
# =============================================================================
|
||||
# .env.production — secrets + dynamic ports for the running containers
|
||||
# =============================================================================
|
||||
#
|
||||
# deploy.sh writes the first three lines on every successful deploy.
|
||||
# Everything below is YOUR responsibility to populate. deploy.sh preserves
|
||||
# unknown lines verbatim across deploys (it only overwrites the lines it
|
||||
# knows about), so you can safely commit this file to a private repo or
|
||||
# provision it via your secrets manager of choice.
|
||||
#
|
||||
# In production, this file should be mode 0600 and owned by the deploy user.
|
||||
# =============================================================================
|
||||
|
||||
# --- managed by deploy.sh (do not edit by hand) -------------------------------
|
||||
POSTGREST_HOST_PORT=3011
|
||||
NEXTJS_HOST_PORT=3012
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3011
|
||||
|
||||
# --- PostgREST connection ---------------------------------------------------
|
||||
PGRST_DB_URI=postgres://app:secret@db.internal:5432/app_production
|
||||
PGRST_DB_ANON_ROLE=anon
|
||||
PGRST_DB_SCHEMA=public
|
||||
|
||||
# --- Next.js server-side secrets -------------------------------------------
|
||||
# Anything not prefixed NEXT_PUBLIC_ is server-only and read at request time.
|
||||
DATABASE_URL=postgres://app:secret@db.internal:5432/app_production
|
||||
|
||||
# Auth.js v5 (NextAuth). Generate AUTH_SECRET with `npx auth secret` or
|
||||
# `openssl rand -base64 32`. AUTH_URL is the public base URL the browser
|
||||
# uses to build OAuth callback URLs.
|
||||
AUTH_SECRET=change-me-to-a-long-random-string
|
||||
AUTH_URL=https://app.example.com
|
||||
NEXT_PUBLIC_AUTH_URL=https://app.example.com
|
||||
ALLOW_DEV_LOGIN=false
|
||||
|
||||
# Google OAuth provider for Auth.js. Set both AUTH_GOOGLE_ID/SECRET and
|
||||
# GOOGLE_CLIENT_ID/SECRET (the v5 code reads either name).
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
|
||||
# --- External services ------------------------------------------------------
|
||||
STRIPE_SECRET_KEY=sk_live_replace_me
|
||||
STRIPE_PUBLISHABLE_KEY=pk_live_replace_me
|
||||
STRIPE_WEBHOOK_SECRET=whsec_replace_me
|
||||
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASSWORD=replace_me
|
||||
SMTP_FROM="My App <noreply@example.com>"
|
||||
@@ -0,0 +1,6 @@
|
||||
# Runtime artefacts written by deploy.sh — do NOT commit these.
|
||||
.deploy.lock
|
||||
deploy.log
|
||||
.postgrest-port
|
||||
.nextjs-port
|
||||
.env.production
|
||||
@@ -0,0 +1,79 @@
|
||||
# =============================================================================
|
||||
# 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"]
|
||||
@@ -0,0 +1,54 @@
|
||||
# =============================================================================
|
||||
# Makefile — convenience targets around deploy.sh
|
||||
# =============================================================================
|
||||
# All targets are wrappers; you can also invoke deploy.sh directly.
|
||||
|
||||
SHELL := /usr/bin/env bash
|
||||
.SHELLFLAGS := -Eeu -o pipefail -c
|
||||
.SHELLFLAGS_LOG := $(.SHELLFLAGS)
|
||||
|
||||
DEPLOY := ./deploy.sh
|
||||
HEALTH := ./healthcheck.sh
|
||||
WORKSPACE ?= $(CURDIR)
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help message
|
||||
@awk 'BEGIN {FS = ":.*##"; printf "Targets:\n"} /^[a-zA-Z_-]+:.*##/ { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
|
||||
|
||||
.PHONY: deploy
|
||||
deploy: ## Run a full deploy (build + up + nginx + healthcheck)
|
||||
$(DEPLOY)
|
||||
|
||||
.PHONY: deploy-verbose
|
||||
deploy-verbose: ## Deploy with extra logging (PRUNE_IMAGES=0, longer healthcheck)
|
||||
PRUNE_IMAGES=0 HEALTHCHECK_TIMEOUT=120 $(DEPLOY)
|
||||
|
||||
.PHONY: health
|
||||
health: ## Run a one-shot health check against the running stack
|
||||
WORKSPACE=$(WORKSPACE) $(HEALTH)
|
||||
|
||||
.PHONY: health-nginx
|
||||
health-nginx: ## Health check including the nginx-fronted URL
|
||||
WORKSPACE=$(WORKSPACE) $(HEALTH) --nginx
|
||||
|
||||
.PHONY: status
|
||||
status: ## Show current prod ports and running containers
|
||||
@echo "PostgREST port: $$(cat .postgrest-port 2>/dev/null || echo none)"
|
||||
@echo "Next.js port: $$(cat .nextjs-port 2>/dev/null || echo none)"
|
||||
@cd deploy && docker compose -p prod-app ps
|
||||
|
||||
.PHONY: logs
|
||||
logs: ## Tail deploy.log
|
||||
tail -n 200 -f deploy.log
|
||||
|
||||
.PHONY: down
|
||||
down: ## Stop the production stack (without redeploying)
|
||||
cd deploy && docker compose -p prod-app down --remove-orphans
|
||||
|
||||
.PHONY: rollback
|
||||
rollback: ## Restart the previous stack (the one whose ports are still on disk)
|
||||
@if [[ ! -f .postgrest-port ]]; then echo "no .postgrest-port to roll back to"; exit 1; fi
|
||||
cd deploy && \
|
||||
POSTGREST_HOST_PORT=$$(cat ../.postgrest-port) \
|
||||
NEXTJS_HOST_PORT=$$(cat ../.nextjs-port) \
|
||||
docker compose -p prod-app --env-file ../.env.production up -d
|
||||
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# deploy.sh — Idempotent PostgREST + Next.js production deploy
|
||||
# =============================================================================
|
||||
#
|
||||
# Self-hosted single-server deploy. Triggered manually, by Gitea webhook, or
|
||||
# by a Gitea Actions runner after a push to `main` (or `gitea-sync`).
|
||||
#
|
||||
# What it does, in order:
|
||||
# 1. Acquires an exclusive flock (concurrent deploys die loudly).
|
||||
# 2. CLEANUP: stops the dev stack on :3001 and the previous prod stack
|
||||
# (port read from .postgrest-port / .nextjs-port).
|
||||
# 3. PORT_SELECTION: picks the lowest free port in [3011..30200] for
|
||||
# PostgREST, then the next free one for the Next.js frontend.
|
||||
# 4. BUILD: runs `npm run build` with NEXT_PUBLIC_API_URL exported so it
|
||||
# gets inlined into the client bundle.
|
||||
# 5. DEPLOY: writes the chosen ports to .env.production, brings the
|
||||
# compose stack up.
|
||||
# 6. NGINX: renders the nginx config from a template (with the current
|
||||
# ports), `nginx -t`s it, and reloads the host systemd nginx.
|
||||
# 7. HEALTHCHECK: curls the new stack; if anything is down, rolls back.
|
||||
# 8. IMAGE_PRUNE: optional, removes dangling images on success.
|
||||
#
|
||||
# Files written to the workspace root:
|
||||
# .postgrest-port current PostgREST host port (atomic)
|
||||
# .nextjs-port current Next.js host port (atomic)
|
||||
# .env.production rendered env fed to docker compose
|
||||
# .deploy.lock flock target
|
||||
# deploy.log append-only log
|
||||
# =============================================================================
|
||||
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configurable variables (override via environment before invoking)
|
||||
# -----------------------------------------------------------------------------
|
||||
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-${WORKSPACE}/deploy}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-${COMPOSE_DIR}/docker-compose.yml}"
|
||||
NGINX_TEMPLATE="${NGINX_TEMPLATE:-${COMPOSE_DIR}/nginx.conf.template}"
|
||||
NGINX_RENDERED="${NGINX_RENDERED:-/etc/nginx/sites-available/prod-app.conf}"
|
||||
NGINX_LINK="${NGINX_LINK:-/etc/nginx/sites-enabled/prod-app.conf}"
|
||||
NGINX_OWNER="${NGINX_OWNER:-www-data:www-data}"
|
||||
|
||||
PROJECT_NAME="${PROJECT_NAME:-prod-app}"
|
||||
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
|
||||
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
|
||||
ENV_FILE="${ENV_FILE:-${WORKSPACE}/.env.production}"
|
||||
LOCK_FILE="${LOCK_FILE:-${WORKSPACE}/.deploy.lock}"
|
||||
LOG_FILE="${LOG_FILE:-${WORKSPACE}/deploy.log}"
|
||||
|
||||
DEV_PORT="${DEV_PORT:-3001}"
|
||||
PORT_RANGE_START="${PORT_RANGE_START:-3011}"
|
||||
PORT_RANGE_END="${PORT_RANGE_END:-30200}"
|
||||
HEALTHCHECK_TIMEOUT="${HEALTHCHECK_TIMEOUT:-60}" # seconds total
|
||||
HEALTHCHECK_INTERVAL="${HEALTHCHECK_INTERVAL:-2}" # seconds between tries
|
||||
|
||||
# Image pruning (set PRUNE_IMAGES=0 to skip)
|
||||
PRUNE_IMAGES="${PRUNE_IMAGES:-1}"
|
||||
|
||||
# Optional: pin the public URL the browser uses. If empty, we default to
|
||||
# http://localhost:${POSTGREST_HOST_PORT}. For production with a real domain
|
||||
# and nginx in front, set e.g. NEXT_PUBLIC_API_URL=https://app.example.com/api
|
||||
NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging — every line is timestamped, tee'd to stdout AND the log file.
|
||||
# We replace the shell's fd 1/2 with a tee so any tool that prints (npm, docker,
|
||||
# curl) lands in both places automatically.
|
||||
# -----------------------------------------------------------------------------
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||
|
||||
ts() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||
log() { printf '[%s] %s\n' "$(ts)" "$*"; }
|
||||
hr() { printf '%s\n' '----------------------------------------------------------------'; }
|
||||
section() { hr; log "== $* =="; hr; }
|
||||
|
||||
# Trap so we always release the lock and surface a useful message.
|
||||
on_exit() {
|
||||
local exit_code=$?
|
||||
if (( exit_code != 0 )); then
|
||||
log "DEPLOY FAILED with exit code ${exit_code}"
|
||||
log "See ${LOG_FILE} for full output. Rollback hints:"
|
||||
log " - Previous port was: ${PREVIOUS_POSTGREST_PORT:-<unknown>}"
|
||||
log " - Current .postgrest-port value: $(read_port_file "$POSTGREST_PORT_FILE" || echo '<none>')"
|
||||
log " - To restart the old stack manually:"
|
||||
log " POSTGREST_HOST_PORT=${PREVIOUS_POSTGREST_PORT:-3011} \\"
|
||||
log " NEXTJS_HOST_PORT=${PREVIOUS_NEXTJS_PORT:-3012} \\"
|
||||
log " docker compose -p ${PROJECT_NAME} --env-file ${ENV_FILE} up -d"
|
||||
else
|
||||
log "DEPLOY OK — PostgREST on :${NEW_POSTGREST_PORT}, Next.js on :${NEW_NEXTJS_PORT}"
|
||||
fi
|
||||
# flock on fd 9 releases automatically when the script exits.
|
||||
}
|
||||
trap on_exit EXIT
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
read_port_file() {
|
||||
# Echo the port in $1, or empty string if missing/garbage.
|
||||
local f="$1"
|
||||
[[ -f "$f" ]] || return 1
|
||||
local v
|
||||
v=$(tr -d '[:space:]' < "$f" 2>/dev/null || true)
|
||||
[[ "$v" =~ ^[0-9]+$ ]] || return 1
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
render_template() {
|
||||
# Portable envsubst: replaces $VAR and ${VAR} references in stdin with
|
||||
# values from the current environment. Only the variable names given as
|
||||
# args are expanded (matches `envsubst` behavior). If real envsubst is
|
||||
# available we use it for speed.
|
||||
local vars="$1"
|
||||
if command -v envsubst >/dev/null 2>&1; then
|
||||
envsubst "$vars"
|
||||
else
|
||||
# Build a sed expression like: s/\${VAR}/$VAR/g; s/\bVAR\b/$VAR/g
|
||||
local sed_expr=()
|
||||
for v in $vars; do
|
||||
v="${v#\$}"
|
||||
v="${v#\{}"
|
||||
v="${v%\}}"
|
||||
sed_expr+=( -e "s|\${${v}}|${!v:-}|g" )
|
||||
sed_expr+=( -e "s|\$${v}\b|${!v:-}|g" )
|
||||
done
|
||||
sed "${sed_expr[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
is_listening() {
|
||||
# Returns 0 if port $1 has a TCP listener (v4 or v6) on this host.
|
||||
local port="$1"
|
||||
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${port}$"
|
||||
}
|
||||
|
||||
next_free_port() {
|
||||
# Walk PORT_RANGE_START..PORT_RANGE_END and return the first port nobody
|
||||
# is listening on. Returns 1 if none are free.
|
||||
local p
|
||||
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
|
||||
if ! is_listening "$p"; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
atomic_write() {
|
||||
# Write stdin to $1 atomically: write to temp, fsync, rename. This is
|
||||
# what lets us use .postgrest-port as a single source of truth — readers
|
||||
# always see either the old value or the new value, never a half-written one.
|
||||
local target="$1"
|
||||
local tmp
|
||||
tmp=$(mktemp "${target}.tmp.XXXXXX")
|
||||
cat > "$tmp"
|
||||
sync
|
||||
mv -f "$tmp" "$target"
|
||||
}
|
||||
|
||||
free_port() {
|
||||
# Try several strategies to free a port:
|
||||
# 1. docker compose down for our project (idempotent)
|
||||
# 2. brute-force kill of any process bound to the port
|
||||
local port="$1" label="$2"
|
||||
if [[ -z "$port" ]]; then return 0; fi
|
||||
log " ${label} port ${port}: stopping project '${PROJECT_NAME}' (if up)"
|
||||
( cd "$COMPOSE_DIR" && docker compose -p "$PROJECT_NAME" down --remove-orphans --timeout 10 ) \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
if is_listening "$port"; then
|
||||
log " ${label} port ${port}: still listening, attempting pkill"
|
||||
# fuser prints PIDs holding the port; xargs kills them.
|
||||
local pids
|
||||
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
|
||||
if [[ -n "$pids" ]]; then
|
||||
# shellcheck disable=SC2086
|
||||
kill $pids 2>/dev/null || true
|
||||
sleep 1
|
||||
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
|
||||
[[ -n "$pids" ]] && kill -9 $pids 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
if is_listening "$port"; then
|
||||
log " ${label} port ${port}: WARNING — still in use after cleanup"
|
||||
return 1
|
||||
fi
|
||||
log " ${label} port ${port}: free"
|
||||
return 0
|
||||
}
|
||||
|
||||
healthcheck() {
|
||||
# Hit $1 (URL) until it returns 2xx within HEALTHCHECK_TIMEOUT seconds.
|
||||
local url="$1" label="$2" elapsed=0
|
||||
log " ${label}: ${url}"
|
||||
while (( elapsed < HEALTHCHECK_TIMEOUT )); do
|
||||
if curl -fsS --max-time 5 -o /dev/null "$url"; then
|
||||
log " ${label}: OK (after ${elapsed}s)"
|
||||
return 0
|
||||
fi
|
||||
sleep "$HEALTHCHECK_INTERVAL"
|
||||
elapsed=$(( elapsed + HEALTHCHECK_INTERVAL ))
|
||||
done
|
||||
log " ${label}: FAILED after ${HEALTHCHECK_TIMEOUT}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Lock — refuse to run if another deploy is in flight.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "LOCK"
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
log "Another deploy holds ${LOCK_FILE}. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
log "Acquired exclusive lock on ${LOCK_FILE}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 0. Banner
|
||||
# -----------------------------------------------------------------------------
|
||||
section "DEPLOY START"
|
||||
log "Workspace: ${WORKSPACE}"
|
||||
log "Project: ${PROJECT_NAME}"
|
||||
log "Compose: ${COMPOSE_FILE}"
|
||||
log "Nginx tpl: ${NGINX_TEMPLATE}"
|
||||
log "Port range: ${PORT_RANGE_START}..${PORT_RANGE_END}"
|
||||
log "Caller: ${USER:-<unknown>}@$(hostname)"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 1. CLEANUP — port 3001 (dev) and the previous prod ports.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "CLEANUP"
|
||||
|
||||
free_port "$DEV_PORT" "dev"
|
||||
PREVIOUS_POSTGREST_PORT=$(read_port_file "$POSTGREST_PORT_FILE" || true)
|
||||
PREVIOUS_NEXTJS_PORT=$(read_port_file "$NEXTJS_PORT_FILE" || true)
|
||||
log "Previous prod ports: PostgREST=${PREVIOUS_POSTGREST_PORT:-<none>} Next.js=${PREVIOUS_NEXTJS_PORT:-<none>}"
|
||||
|
||||
# Stale-port guard: if the file points to a port that is NOT in our standard
|
||||
# range, or to a port that nothing is listening on anymore, we still tear
|
||||
# down the project (cheap) but we don't try to free the port itself —
|
||||
# someone else might be using it.
|
||||
free_port "${PREVIOUS_POSTGREST_PORT:-}" "prev-postgrest"
|
||||
free_port "${PREVIOUS_NEXTJS_PORT:-}" "prev-nextjs"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 2. PORT_SELECTION — find the two lowest free ports.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "PORT_SELECTION"
|
||||
|
||||
NEW_POSTGREST_PORT=$(next_free_port) || {
|
||||
log "No free port in [${PORT_RANGE_START}..${PORT_RANGE_END}]. Bailing out."
|
||||
exit 2
|
||||
}
|
||||
log "PostgREST: ${NEW_POSTGREST_PORT}"
|
||||
|
||||
# Re-check after allocation, since we want distinct ports for both services.
|
||||
NEW_NEXTJS_PORT=""
|
||||
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
|
||||
if (( p == NEW_POSTGREST_PORT )); then continue; fi
|
||||
if ! is_listening "$p"; then NEW_NEXTJS_PORT="$p"; break; fi
|
||||
done
|
||||
if [[ -z "$NEW_NEXTJS_PORT" ]]; then
|
||||
log "No free port for Next.js after allocating ${NEW_POSTGREST_PORT}. Bailing out."
|
||||
exit 2
|
||||
fi
|
||||
log "Next.js: ${NEW_NEXTJS_PORT}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 3. BUILD — Next.js, with NEXT_PUBLIC_API_URL inlined into the client bundle.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "BUILD"
|
||||
|
||||
cd "$WORKSPACE"
|
||||
|
||||
# Default the public API URL the browser will see.
|
||||
if [[ -z "$NEXT_PUBLIC_API_URL" ]]; then
|
||||
NEXT_PUBLIC_API_URL="http://localhost:${NEW_POSTGREST_PORT}"
|
||||
fi
|
||||
log "NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}"
|
||||
|
||||
# Node-only check: don't try to build if there's no package.json.
|
||||
if [[ -f package.json ]]; then
|
||||
# Make sure the deps are present (idempotent — npm ci is a no-op when locked).
|
||||
if [[ -f package-lock.json ]]; then
|
||||
log "npm ci (locked install)"
|
||||
npm ci --no-audit --no-fund
|
||||
else
|
||||
log "npm install (no lockfile present — consider committing package-lock.json)"
|
||||
npm install --no-audit --no-fund
|
||||
fi
|
||||
log "npm run build"
|
||||
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
|
||||
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
|
||||
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
|
||||
npm run build
|
||||
else
|
||||
log "No package.json in ${WORKSPACE} — skipping build step."
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 4. ENV FILE — render .env.production for the running containers.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "ENV"
|
||||
|
||||
# Preserve any pre-existing secrets in .env.production. We only own the lines
|
||||
# we write; everything else is left alone. (The simplest sane strategy.)
|
||||
SECRETS_FILE=""
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
SECRETS_FILE=$(mktemp)
|
||||
# Drop any lines we manage; keep the rest verbatim.
|
||||
grep -v -E '^(POSTGREST_HOST_PORT|NEXTJS_HOST_PORT|NEXT_PUBLIC_API_URL)=' \
|
||||
"$ENV_FILE" > "$SECRETS_FILE" || true
|
||||
fi
|
||||
|
||||
{
|
||||
printf '# Generated by deploy.sh on %s — safe to edit, lines below are managed\n' "$(ts)"
|
||||
printf 'POSTGREST_HOST_PORT=%s\n' "$NEW_POSTGREST_PORT"
|
||||
printf 'NEXTJS_HOST_PORT=%s\n' "$NEW_NEXTJS_PORT"
|
||||
printf 'NEXT_PUBLIC_API_URL=%q\n' "$NEXT_PUBLIC_API_URL"
|
||||
if [[ -n "$SECRETS_FILE" ]]; then
|
||||
cat "$SECRETS_FILE"
|
||||
rm -f "$SECRETS_FILE"
|
||||
fi
|
||||
} > "${ENV_FILE}.new"
|
||||
|
||||
mv -f "${ENV_FILE}.new" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
log "Wrote ${ENV_FILE}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 5. DEPLOY — bring the stack up.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "DEPLOY"
|
||||
|
||||
cd "$COMPOSE_DIR"
|
||||
log "docker compose -p ${PROJECT_NAME} up -d --build"
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --build
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 6. NGINX — render config from template, test, reload.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "NGINX"
|
||||
|
||||
if [[ -f "$NGINX_TEMPLATE" ]]; then
|
||||
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
|
||||
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
|
||||
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
|
||||
render_template '${POSTGREST_HOST_PORT} ${NEXTJS_HOST_PORT} ${NEXT_PUBLIC_API_URL}' \
|
||||
< "$NGINX_TEMPLATE" > "$NGINX_RENDERED"
|
||||
|
||||
log "Rendered: ${NGINX_RENDERED}"
|
||||
chown "$NGINX_OWNER" "$NGINX_RENDERED" 2>/dev/null || true
|
||||
chmod 644 "$NGINX_RENDERED"
|
||||
|
||||
# Wire it into sites-enabled if not already linked.
|
||||
if [[ ! -L "$NGINX_LINK" && ! -e "$NGINX_LINK" ]]; then
|
||||
log "Enabling site: ${NGINX_LINK} -> ${NGINX_RENDERED}"
|
||||
ln -s "$NGINX_RENDERED" "$NGINX_LINK"
|
||||
fi
|
||||
|
||||
log "nginx -t"
|
||||
nginx -t
|
||||
log "systemctl reload nginx"
|
||||
systemctl reload nginx
|
||||
else
|
||||
log "No nginx template at ${NGINX_TEMPLATE} — skipping reverse proxy step."
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 7. HEALTHCHECK — direct + via nginx (when applicable).
|
||||
# -----------------------------------------------------------------------------
|
||||
section "HEALTHCHECK"
|
||||
|
||||
# Direct checks (bypass nginx, catch compose issues)
|
||||
healthcheck "http://127.0.0.1:${NEW_POSTGREST_PORT}/" "postgrest-direct" || ROLLBACK=1
|
||||
healthcheck "http://127.0.0.1:${NEW_NEXTJS_PORT}/" "nextjs-direct" || ROLLBACK=1
|
||||
|
||||
# nginx-fronted check (only meaningful if nginx template exists)
|
||||
if [[ -f "$NGINX_TEMPLATE" && "${ROLLBACK:-0}" != "1" ]]; then
|
||||
healthcheck "http://127.0.0.1/" "nginx-front" || ROLLBACK=1
|
||||
fi
|
||||
|
||||
if [[ "${ROLLBACK:-0}" == "1" ]]; then
|
||||
log "HEALTHCHECK FAILED — rolling back."
|
||||
log "Tearing down the new stack on :${NEW_POSTGREST_PORT} / :${NEW_NEXTJS_PORT}"
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" down --remove-orphans --timeout 10 || true
|
||||
|
||||
# If we had a previous port file, the old one is still on disk (we wrote
|
||||
# the new one to .new and only mv'd on success... but we DID mv already,
|
||||
# so re-write the old value).
|
||||
if [[ -n "${PREVIOUS_POSTGREST_PORT:-}" ]]; then
|
||||
printf '%s\n' "$PREVIOUS_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
|
||||
else
|
||||
rm -f "$POSTGREST_PORT_FILE"
|
||||
fi
|
||||
if [[ -n "${PREVIOUS_NEXTJS_PORT:-}" ]]; then
|
||||
printf '%s\n' "$PREVIOUS_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
|
||||
else
|
||||
rm -f "$NEXTJS_PORT_FILE"
|
||||
fi
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 8. PERSIST — commit the chosen ports as the new single source of truth.
|
||||
# (Done AFTER healthcheck so a failed deploy doesn't clobber the old one.)
|
||||
# -----------------------------------------------------------------------------
|
||||
section "PERSIST"
|
||||
printf '%s\n' "$NEW_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
|
||||
printf '%s\n' "$NEW_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
|
||||
log ".postgrest-port = ${NEW_POSTGREST_PORT}"
|
||||
log ".nextjs-port = ${NEW_NEXTJS_PORT}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 9. IMAGE_PRUNE — optional housekeeping.
|
||||
# -----------------------------------------------------------------------------
|
||||
if [[ "$PRUNE_IMAGES" == "1" ]]; then
|
||||
section "IMAGE_PRUNE"
|
||||
docker image prune -f
|
||||
fi
|
||||
|
||||
section "DONE"
|
||||
exit 0
|
||||
@@ -0,0 +1,72 @@
|
||||
# =============================================================================
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# healthcheck.sh — standalone, callable from cron / monitoring
|
||||
# =============================================================================
|
||||
#
|
||||
# Reads the current prod ports from .postgrest-port / .nextjs-port and curls
|
||||
# each service. Exit code is the count of failed checks (0 = all healthy).
|
||||
#
|
||||
# Usage:
|
||||
# ./healthcheck.sh
|
||||
# ./healthcheck.sh --nginx # also check the fronted URL
|
||||
# WORKSPACE=/srv/app ./healthcheck.sh
|
||||
# =============================================================================
|
||||
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
|
||||
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
|
||||
TIMEOUT="${HEALTHCHECK_TIMEOUT:-5}"
|
||||
|
||||
failures=0
|
||||
|
||||
check() {
|
||||
local label="$1" url="$2"
|
||||
if curl -fsS --max-time "$TIMEOUT" -o /dev/null "$url"; then
|
||||
printf ' [ OK ] %-20s %s\n' "$label" "$url"
|
||||
else
|
||||
printf ' [FAIL] %-20s %s\n' "$label" "$url"
|
||||
failures=$(( failures + 1 ))
|
||||
fi
|
||||
}
|
||||
|
||||
pgrest_port=$(tr -d '[:space:]' < "$POSTGREST_PORT_FILE" 2>/dev/null || echo "")
|
||||
next_port=$(tr -d '[:space:]' < "$NEXTJS_PORT_FILE" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$pgrest_port" ]]; then
|
||||
check "postgrest" "http://127.0.0.1:${pgrest_port}/"
|
||||
else
|
||||
printf ' [SKIP] postgrest (no .postgrest-port)\n'
|
||||
fi
|
||||
|
||||
if [[ -n "$next_port" ]]; then
|
||||
check "nextjs" "http://127.0.0.1:${next_port}/"
|
||||
else
|
||||
printf ' [SKIP] nextjs (no .nextjs-port)\n'
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "--nginx" ]]; then
|
||||
check "nginx" "http://127.0.0.1/"
|
||||
fi
|
||||
|
||||
exit "$failures"
|
||||
@@ -0,0 +1,89 @@
|
||||
# =============================================================================
|
||||
# nginx.conf.template — rendered by deploy.sh on every deploy
|
||||
# =============================================================================
|
||||
#
|
||||
# Variables substituted by `envsubst`:
|
||||
# ${POSTGREST_HOST_PORT} dynamic host port of the PostgREST container
|
||||
# ${NEXTJS_HOST_PORT} dynamic host port of the Next.js container
|
||||
# ${NEXT_PUBLIC_API_URL} (informational only — used in comment header)
|
||||
#
|
||||
# Layout:
|
||||
# /api/* -> http://127.0.0.1:${POSTGREST_HOST_PORT}
|
||||
# /* -> http://127.0.0.1:${NEXTJS_HOST_PORT}
|
||||
#
|
||||
# Tested against nginx >= 1.18 (Debian 11 / Ubuntu 22.04). Adjust ssl_*
|
||||
# lines if you don't have a cert yet — deploy.sh only tests/renders, the
|
||||
# operator decides whether to terminate TLS here.
|
||||
# =============================================================================
|
||||
|
||||
# --- upstream definitions ---------------------------------------------------
|
||||
upstream postgrest_upstream {
|
||||
server 127.0.0.1:${POSTGREST_HOST_PORT};
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
upstream nextjs_upstream {
|
||||
server 127.0.0.1:${NEXTJS_HOST_PORT};
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
# --- HTTP -> HTTPS upgrade (optional; remove if you only run on LAN) --------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
# ACME http-01 challenge needs to be served on port 80.
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/letsencrypt;
|
||||
}
|
||||
|
||||
# Redirect everything else to HTTPS. Comment out for plain-HTTP dev.
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# --- main server block ------------------------------------------------------
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name _;
|
||||
|
||||
# --- TLS (uncomment + adjust after you obtain a cert) ------------------
|
||||
# ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# --- sensible defaults ------------------------------------------------
|
||||
client_max_body_size 25m;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# --- API: /api/* -> PostgREST ----------------------------------------
|
||||
location /api/ {
|
||||
proxy_pass http://postgrest_upstream;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
|
||||
# PostgREST exposes its OpenAPI spec at the root of the API; expose it
|
||||
# under a stable URL too.
|
||||
location = /api {
|
||||
proxy_pass http://postgrest_upstream;
|
||||
}
|
||||
|
||||
# --- everything else -> Next.js --------------------------------------
|
||||
location / {
|
||||
proxy_pass http://nextjs_upstream;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
# 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.
|
||||
@@ -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) |
|
||||
| Source | **544 files** (362 .tsx + 179 .ts) |
|
||||
| Admin pages | **88 routes** across orders, products, stops, wholesale, communications, time-tracking, water-log, route-trace, reports, advanced, command-center |
|
||||
| Admin pages | **87 routes** across orders, products, stops, wholesale, communications, time-tracking, water-log, route-trace, reports, advanced |
|
||||
| API routes | **60+ endpoints** (Stripe + Square + FedEx + Resend + Twilio + 8 AI endpoints) |
|
||||
| Public surfaces | 2 brand storefronts (Tuxedo, Indian River Direct) with brand theming, hero, color customization |
|
||||
| Integrations | Stripe (Connect + subscriptions), Square (POS sync), FedEx (rates + labels), Resend (email), Twilio (SMS), OpenAI (8 AI tools) |
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,147 @@
|
||||
# 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".
|
||||
@@ -0,0 +1,470 @@
|
||||
# Multi-Brand Admin Support
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Status:** Draft → Approved
|
||||
**Author:** Grok (brainstorming session)
|
||||
**Migration file:** `supabase/migrations/204_multi_brand_admin.sql`
|
||||
**Follow-up migration (out of scope):** `220_drop_legacy_brand_id.sql`
|
||||
|
||||
## Problem
|
||||
|
||||
`admin_users.brand_id` is a single `UUID | null`. The platform supports a `platform_admin` role (no brand) and a `brand_admin` role (one brand). There is no representation for an admin who legitimately needs access to 2+ specific brands — e.g., a parent company operating multiple storefronts with shared operations staff.
|
||||
|
||||
The current `effectiveBrandId = brandId ?? adminUser.brand_id ?? null` pattern silently does the wrong thing for multi-brand use cases:
|
||||
|
||||
- Data model can't represent "Jane is admin for Brand A AND Brand B" — would require two `admin_users` rows (and the auth-user→admin-user join gets messy).
|
||||
- No central validation that an admin is acting in a brand they actually have access to.
|
||||
- No persistent "current brand" context — every page re-derives it from scratch.
|
||||
- No per-brand permission overrides possible (locks in flexibility for the future).
|
||||
|
||||
This spec addresses multi-brand tenants / franchises: same staff managing multiple brands under a parent org, where brands are separate for storefront/billing but share operations.
|
||||
|
||||
## Goals
|
||||
|
||||
- An admin can be associated with multiple brands via a junction table.
|
||||
- A persistent "active brand" is stored in a cookie, switchable via UI, and used as the default when no explicit brand is requested.
|
||||
- A new `multi_brand_admin` role makes the relationship explicit in the data and the UI.
|
||||
- `platform_admin` continues to work unchanged (gets all brands implicitly).
|
||||
- Existing single-brand `brand_admin`, `store_employee`, and `staff` users continue to work with zero behavior change.
|
||||
- Server actions get a single, central place to resolve and validate the active brand.
|
||||
|
||||
## Non-Goals (YAGNI)
|
||||
|
||||
- Per-(admin, brand) permission overrides. The user explicitly chose "same perms across all brands."
|
||||
- Brand-group / parent-org concept. The junction table makes this possible later, but it's not built now.
|
||||
- "Last accessed brand" auto-redirect. The cookie is the source of truth; no extra logic.
|
||||
- UI for managing `admin_user_brands` rows. Use Supabase Studio or a follow-up admin UI PR.
|
||||
- Dropping the legacy `admin_users.brand_id` column. A follow-up migration `220_drop_legacy_brand_id.sql` will do this after we verify nothing reads it. Out of scope for this spec.
|
||||
|
||||
## Approach: Junction Table + Backwards-Compat `brand_id`
|
||||
|
||||
Selected from among three options:
|
||||
|
||||
| Option | Why not |
|
||||
|---|---|
|
||||
| A. Junction + keep `brand_id` (selected) | — |
|
||||
| B. Junction only, drop `brand_id` | High migration risk; every server action reference must change. |
|
||||
| C. `brand_ids UUID[]` on `admin_users` | No FK, awkward reverse lookups, locks out per-brand metadata. |
|
||||
|
||||
A is the lowest-risk additive path that solves the problem.
|
||||
|
||||
## Data Model
|
||||
|
||||
### New table: `admin_user_brands`
|
||||
|
||||
```sql
|
||||
CREATE TABLE admin_user_brands (
|
||||
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
added_by UUID REFERENCES admin_users(id),
|
||||
PRIMARY KEY (admin_user_id, brand_id)
|
||||
);
|
||||
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
|
||||
```
|
||||
|
||||
- Composite PK enforces uniqueness.
|
||||
- Index on `brand_id` makes "which admins are in Brand X?" queries fast.
|
||||
- `added_at` / `added_by` for audit trail.
|
||||
- `ON DELETE CASCADE` for both FKs — deleting an admin or brand cleans up the junction.
|
||||
|
||||
### New role: `multi_brand_admin`
|
||||
|
||||
Added to the `role` CHECK constraint. Functionally equivalent to `brand_admin` permission-wise — same `can_manage_*` flags apply. The role label disambiguates intent in the UI ("this person manages 3 brands" vs "this person manages 1 brand") and in audit logs.
|
||||
|
||||
### Updated `AdminUser` type
|
||||
|
||||
```ts
|
||||
// src/lib/admin-permissions-types.ts
|
||||
export type AdminUser = {
|
||||
// ... existing fields
|
||||
brand_id: string | null; // active brand (one of brand_ids, or null for platform_admin)
|
||||
brand_ids: string[]; // all brands this admin can act in
|
||||
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
|
||||
};
|
||||
```
|
||||
|
||||
### Membership rules
|
||||
|
||||
| Role | `brand_id` (active) | `brand_ids` (membership) |
|
||||
|---|---|---|
|
||||
| `platform_admin` | `null` (or cookie-selected brand) | Implicitly all brands; `brand_ids` populated by `listBrandsForAdmin` querying the `brands` table |
|
||||
| `multi_brand_admin` | First/selected brand | 2+ brands from `admin_user_brands` |
|
||||
| `brand_admin` | Their single brand | `[that one brand]` |
|
||||
| `store_employee` | Their single brand | `[that one brand]` |
|
||||
| `staff` | Their single brand | `[that one brand]` |
|
||||
|
||||
For `platform_admin`, the application layer short-circuits brand-access checks (`if (adminUser.role === "platform_admin") return ...`). The `brand_ids` field is only used by `listBrandsForAdmin` to render the dropdown options; it is not used for permission gating for `platform_admin`.
|
||||
|
||||
### `getAdminUser()` resolution order
|
||||
|
||||
1. If `dev_session` cookie set → return dev admin. For `platform_admin` dev: `brand_id: null, brand_ids: []` (resolved against `brands` table by `listBrandsForAdmin`). For `store_employee` dev: `brand_id: <first-real-brand-id>, brand_ids: [<that-id>]` — fetched from the `brands` table so dev store_employee can browse a real brand's data. (If no brands exist, dev store_employee sees `<AdminAccessDenied />` — known limitation.)
|
||||
2. If `NEXT_PUBLIC_USE_MOCK_DATA=true` → same as `platform_admin` dev.
|
||||
3. Real auth → load `admin_users` row, then JOIN `admin_user_brands` to populate `brand_ids`.
|
||||
4. Set `brand_id` from (in order): `active_brand_id` cookie (if in `brand_ids` for non-platform-admin, or always for platform-admin) → `admin_users.brand_id` (if in `brand_ids`) → first of `brand_ids`.
|
||||
|
||||
## Server Action Patterns
|
||||
|
||||
### New file: `src/lib/brand-scope.ts`
|
||||
|
||||
```ts
|
||||
import { cookies } from "next/headers";
|
||||
import type { AdminUser } from "./admin-permissions-types";
|
||||
|
||||
const ACTIVE_BRAND_COOKIE = "active_brand_id";
|
||||
|
||||
export async function getActiveBrandId(
|
||||
adminUser: AdminUser,
|
||||
requested?: string | null
|
||||
): Promise<string | null> {
|
||||
// Cookie is the source of truth for "what brand am I acting in right now"
|
||||
// for everyone — including platform_admin (who can pin a specific brand
|
||||
// or fall back to null = "all brands").
|
||||
const cookieStore = await cookies();
|
||||
const cookieBrand = cookieStore.get(ACTIVE_BRAND_COOKIE)?.value ?? null;
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
// requested > cookie > null (all brands)
|
||||
return requested ?? cookieBrand ?? null;
|
||||
}
|
||||
|
||||
// Non-platform-admin: requested (if in brand_ids) > cookie (if in brand_ids) > adminUser.brand_id
|
||||
if (requested) {
|
||||
return adminUser.brand_ids.includes(requested) ? requested : null;
|
||||
}
|
||||
if (cookieBrand && adminUser.brand_ids.includes(cookieBrand)) {
|
||||
return cookieBrand;
|
||||
}
|
||||
return adminUser.brand_id;
|
||||
}
|
||||
|
||||
export async function setActiveBrandCookie(brandId: string): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(ACTIVE_BRAND_COOKIE, brandId, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearActiveBrandCookie(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(ACTIVE_BRAND_COOKIE);
|
||||
}
|
||||
|
||||
export function assertBrandAccess(adminUser: AdminUser, brandId: string): void {
|
||||
if (adminUser.role === "platform_admin") return;
|
||||
if (!adminUser.brand_ids.includes(brandId)) {
|
||||
throw new Error("Brand access denied");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Server action: set active brand
|
||||
|
||||
```ts
|
||||
// src/actions/admin/set-active-brand.ts
|
||||
"use server";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { setActiveBrandCookie, clearActiveBrandCookie } from "@/lib/brand-scope";
|
||||
|
||||
export async function setActiveBrand(brandId: string | null): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// null = "All brands" (platform_admin only)
|
||||
if (brandId === null) {
|
||||
if (adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Only platform admins can select 'All brands'" };
|
||||
}
|
||||
await clearActiveBrandCookie();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (adminUser.role !== "platform_admin" && !adminUser.brand_ids.includes(brandId)) {
|
||||
return { success: false, error: "No access to that brand" };
|
||||
}
|
||||
await setActiveBrandCookie(brandId);
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
### New server function: list brands for admin
|
||||
|
||||
```ts
|
||||
// src/actions/brands.ts
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export async function listBrandsForAdmin(): Promise<
|
||||
{ id: string; name: string; slug: string; logo_url: string | null }[]
|
||||
> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
|
||||
{ headers: svcHeaders(serviceKey) }
|
||||
);
|
||||
return res.ok ? await res.json() : [];
|
||||
}
|
||||
|
||||
if (adminUser.brand_ids.length === 0) return [];
|
||||
|
||||
const filter = `id=in.(${adminUser.brand_ids.map((id) => `"${id}"`).join(",")})`;
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
|
||||
{ headers: svcHeaders(serviceKey) }
|
||||
);
|
||||
return res.ok ? await res.json() : [];
|
||||
}
|
||||
```
|
||||
|
||||
### Server action migration pattern
|
||||
|
||||
```ts
|
||||
// Before:
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||
|
||||
// After:
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
```
|
||||
|
||||
This is a mechanical one-line swap in ~30 server actions identified by the grep:
|
||||
|
||||
- `src/actions/wholesale.ts` (multiple)
|
||||
- `src/actions/products.ts`
|
||||
- `src/actions/communications/templates.ts`
|
||||
- `src/actions/communications/campaigns.ts`
|
||||
- `src/actions/orders/create-admin-order.ts`
|
||||
- `src/actions/stops.ts`
|
||||
- `src/actions/analytics.ts`
|
||||
- `src/actions/square-sync-ui.ts`
|
||||
- `src/actions/shipping.ts`
|
||||
- `src/actions/payments.ts`
|
||||
- `src/actions/ai-import.ts`
|
||||
- `src/actions/wholesale-register.ts`
|
||||
|
||||
### Page server component pattern
|
||||
|
||||
```ts
|
||||
// Before (src/app/admin/orders/page.tsx):
|
||||
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
|
||||
|
||||
// After:
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
|
||||
export default async function AdminOrdersPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
||||
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
|
||||
// ... rest of the page
|
||||
}
|
||||
```
|
||||
|
||||
This applies to every page under `src/app/admin/` — including the existing `/admin/taxes/[brandId]` and `/admin/settings/billing/[brandId]` which already do brand param resolution.
|
||||
|
||||
## UI Components
|
||||
|
||||
### Brand selector
|
||||
|
||||
**Location:** `src/components/admin/AdminHeader.tsx` (the top bar already rendered on every `/admin/*` page).
|
||||
|
||||
**Behavior:** A dropdown showing:
|
||||
- Brand logo + name (current active brand)
|
||||
- Chevron
|
||||
- "All brands" option at the top (only for `platform_admin`)
|
||||
- List of accessible brands (`adminUser.brand_ids`)
|
||||
- Small badge "Multi-brand manager" next to the user's name when `role === "multi_brand_admin"`
|
||||
|
||||
**Visibility matrix:**
|
||||
|
||||
| Admin | Show dropdown? | Options |
|
||||
|---|---|---|
|
||||
| `platform_admin` | Yes | "All brands" + list of all brands |
|
||||
| `multi_brand_admin` (2+ brands) | Yes | List of their brands |
|
||||
| `brand_admin` / `store_employee` / `staff` (1 brand) | No | — |
|
||||
| `platform_admin` (dev_session) | Yes | "All brands" + list of all brands (same UX as production) |
|
||||
|
||||
**On select:**
|
||||
|
||||
```ts
|
||||
// src/components/admin/BrandSelector.tsx (client component)
|
||||
"use client";
|
||||
async function handleSelect(brandId: string | null) {
|
||||
await setActiveBrand(brandId); // null = "All brands"
|
||||
router.refresh();
|
||||
}
|
||||
```
|
||||
|
||||
`router.refresh()` re-runs server components and re-reads the cookie, so all data on the current page reloads in the new brand context. The URL is **not** changed — the cookie is the source of truth for "what brand am I acting in right now."
|
||||
|
||||
### URL-level brand params
|
||||
|
||||
Keep URL-level brand as-is — URLs are shareable links. The resolution order for the `brandId` param passed to `getActiveBrandId` is:
|
||||
|
||||
1. URL `brandId` param (if present)
|
||||
2. `active_brand_id` cookie
|
||||
3. `adminUser.brand_id` (legacy single brand)
|
||||
4. First of `adminUser.brand_ids`
|
||||
5. (platform_admin only) `null` → "all brands"
|
||||
|
||||
## Migration: `supabase/migrations/204_multi_brand_admin.sql`
|
||||
|
||||
```sql
|
||||
-- 1. Add multi_brand_admin to role CHECK constraint
|
||||
ALTER TABLE admin_users DROP CONSTRAINT IF EXISTS admin_users_role_check;
|
||||
ALTER TABLE admin_users ADD CONSTRAINT admin_users_role_check
|
||||
CHECK (role IN ('platform_admin', 'brand_admin', 'multi_brand_admin', 'store_employee', 'staff'));
|
||||
|
||||
-- 2. Create junction table
|
||||
CREATE TABLE admin_user_brands (
|
||||
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
added_by UUID REFERENCES admin_users(id),
|
||||
PRIMARY KEY (admin_user_id, brand_id)
|
||||
);
|
||||
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
|
||||
|
||||
-- 3. Backfill from existing brand_id (single-brand admins)
|
||||
INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
||||
SELECT id, brand_id FROM admin_users
|
||||
WHERE brand_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 4. Promote anyone with > 1 brand to multi_brand_admin
|
||||
UPDATE admin_users
|
||||
SET role = 'multi_brand_admin'
|
||||
WHERE role = 'brand_admin'
|
||||
AND id IN (
|
||||
SELECT admin_user_id FROM admin_user_brands
|
||||
GROUP BY admin_user_id HAVING COUNT(*) > 1
|
||||
);
|
||||
|
||||
-- 5. New RPCs for adding/removing brand access
|
||||
CREATE OR REPLACE FUNCTION add_admin_user_brand(
|
||||
p_admin_user_id UUID, p_brand_id UUID, p_added_by UUID
|
||||
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
|
||||
INSERT INTO admin_user_brands (admin_user_id, brand_id, added_by)
|
||||
VALUES (p_admin_user_id, p_brand_id, p_added_by)
|
||||
ON CONFLICT DO NOTHING;
|
||||
UPDATE admin_users SET role = 'multi_brand_admin'
|
||||
WHERE id = p_admin_user_id
|
||||
AND role = 'brand_admin'
|
||||
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) > 1;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION remove_admin_user_brand(
|
||||
p_admin_user_id UUID, p_brand_id UUID
|
||||
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
|
||||
DELETE FROM admin_user_brands
|
||||
WHERE admin_user_id = p_admin_user_id AND brand_id = p_brand_id;
|
||||
UPDATE admin_users SET role = 'brand_admin'
|
||||
WHERE id = p_admin_user_id
|
||||
AND role = 'multi_brand_admin'
|
||||
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) = 1;
|
||||
$$;
|
||||
```
|
||||
|
||||
The `admin_users.brand_id` column is **kept** for backwards compat. A follow-up migration drops it after we verify nothing reads it.
|
||||
|
||||
## Error Handling & Security Boundaries
|
||||
|
||||
### Three failure modes
|
||||
|
||||
1. **Requested brand not in `brand_ids`** (e.g., URL param has a brand the admin doesn't have):
|
||||
- `getActiveBrandId()` returns `null`
|
||||
- Server action returns `{ success: false, error: "Brand access required" }`
|
||||
- Page renders `<AdminAccessDenied />` with: "You don't have access to that brand. [Switch to a brand you have access to]"
|
||||
|
||||
2. **Cookie brand no longer in `brand_ids`** (admin's access was revoked while cookie was still set):
|
||||
- `getActiveBrandId()` falls through to `adminUser.brand_id`, then first of `brand_ids`
|
||||
- Silent recovery — no error, no UI flash
|
||||
- The dropped brand just disappears from the dropdown next page load
|
||||
|
||||
3. **Platform admin acting on a brand they don't own:**
|
||||
- Platform admin: `brand_ids = ["*"]` (sentinel) — RPCs treat as "all brands"
|
||||
- Server action: never blocks platform admin from any brand
|
||||
- This is intentional — platform admin = superuser
|
||||
|
||||
### Validation placement (defense in depth)
|
||||
|
||||
- Server action: `getActiveBrandId(adminUser, requested)` validates.
|
||||
- Server action: `assertBrandAccess(adminUser, brandIdFromUrl)` validates (separate, for cases where the brandId comes from URL/form/RPC return rather than `getActiveBrandId`).
|
||||
- RPC: still trusts `p_brand_id` (SECURITY DEFINER) — application layer is the gate, matching the existing architecture.
|
||||
|
||||
### Audit logging (additive)
|
||||
|
||||
- `admin_user_brands.added_by` column tracks who added an admin to a brand.
|
||||
- Audit log entry on add/remove: out of scope for v1; documented for follow-up.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests (`vitest` — new dev dependency)
|
||||
|
||||
- `src/lib/brand-scope.test.ts`:
|
||||
- `resolveActiveBrandId(platformAdmin, "X")` → `"X"`
|
||||
- `resolveActiveBrandId(brandAdmin, "X")` where X is their brand → `"X"`
|
||||
- `resolveActiveBrandId(brandAdmin, "Y")` where Y is not their brand → `null`
|
||||
- `getActiveBrandId(multiBrandAdmin)` with cookie set to valid brand → that brand
|
||||
- `getActiveBrandId(multiBrandAdmin)` with cookie set to revoked brand → first of `brand_ids`
|
||||
- `assertBrandAccess(...)` throws for non-platform-admin with invalid brand
|
||||
- `setActiveBrand(null)` rejected for non-platform-admin
|
||||
- `setActiveBrand("X")` rejected for admin without X in `brand_ids`
|
||||
|
||||
### Integration tests (Playwright — already in repo)
|
||||
|
||||
- `tests/admin/multi-brand.spec.ts`:
|
||||
- As `multi_brand_admin`, dropdown shows 2+ brands
|
||||
- Click brand B → URL stays, cookie updates, page data refreshes to brand B
|
||||
- Direct-navigate to `/admin/orders?brand=<other-brand>` for a brand admin returns access denied
|
||||
- As `platform_admin`, "All brands" option is present and works
|
||||
- As `brand_admin` with 1 brand, no dropdown is shown
|
||||
|
||||
### Migration smoke test (manual, documented in MEMORY.md)
|
||||
|
||||
- Before migration: 5 brand_admins exist, each with 1 brand
|
||||
- After migration: 5 rows in `admin_user_brands`, all `role = 'brand_admin'`
|
||||
- Create a 6th admin with 2 brands via `add_admin_user_brand` → `role = 'multi_brand_admin'`, 2 rows in junction
|
||||
- Remove one of their brands via `remove_admin_user_brand` → `role` demotes to `brand_admin`, 1 row in junction
|
||||
|
||||
## Out of Scope (v1)
|
||||
|
||||
- Per-(admin, brand) permission overrides
|
||||
- Brand-group / parent-org concept
|
||||
- "Last accessed brand" auto-redirect
|
||||
- UI for managing `admin_user_brands` rows (Supabase Studio works for now)
|
||||
- Dropping the legacy `admin_users.brand_id` column (follow-up `220_*` migration)
|
||||
- Audit log entries for add/remove (junction's `added_by` column is the seed)
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Migration `204_multi_brand_admin.sql` applied
|
||||
2. `src/lib/brand-scope.ts` + unit tests (TDD)
|
||||
3. `AdminUser` type updated; `getAdminUser()` returns `brand_ids`
|
||||
4. `setActiveBrand` server action + tests
|
||||
5. `listBrandsForAdmin` server function + tests
|
||||
6. BrandSelector UI component
|
||||
7. Wire BrandSelector into AdminHeader
|
||||
8. Server action migration (~30 actions) — mechanical one-line swap each
|
||||
9. Page server component migration (~10+ pages) — same pattern
|
||||
10. Playwright integration tests
|
||||
11. Manual smoke test of the migration on dev DB
|
||||
@@ -0,0 +1,538 @@
|
||||
# Admin Mobile PWA — Design Spec
|
||||
|
||||
- **Date:** 2026-06-17
|
||||
- **Status:** Draft, awaiting user review
|
||||
- **Author:** Grok
|
||||
- **Branch:** `main`
|
||||
- **Scope:** Admin dashboard only (`/admin/*`), with full PWA + offline support
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Route Commerce is used heavily on phones — the team operates out of offices, trucks, warehouses, and outdoor pickup stops. The current admin is mobile-responsive but **not mobile-first**, the PWA is half-built (manifest + service worker exist, but no icons, no install prompt wired up, no offline page, no service worker registration), and Apple HIG accessibility-tier legibility is not the design baseline.
|
||||
|
||||
**Symptoms today:**
|
||||
|
||||
- `public/icons/` and `public/screenshots/` directories don't exist; manifest references 8 icon files and 2 screenshots that all 404
|
||||
- `registerServiceWorker()` is exported but never called
|
||||
- `PWAInstallPrompt` is never mounted in the tree
|
||||
- `apple-touch-icon.png` and `favicon.ico` are referenced but missing
|
||||
- Service worker references `/offline` and `/og-default.jpg` that don't exist
|
||||
- `themeColor` in `viewport` (`#0a0a0a`) mismatches manifest (`#1a4d2e`)
|
||||
- `AdminSidebar` uses `lg:pl-60` — on phones it becomes an overlay drawer; no bottom nav, no thumb-zone ergonomics
|
||||
- Body text is 16pt; tap targets are 40–44pt; status colors don't all clear WCAG AA on the warm cream background
|
||||
- No offline mutation queue — losing signal mid-fulfillment is a real operational problem
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Ship a real, installable PWA** — manifest validates, all icons render, splash + app icon work on iOS Add to Home Screen, themed status bar.
|
||||
2. **Mobile-first admin shell** — bottom tab bar (thumb zone) for the 4 most-used surfaces, drag-up "More" sheet for everything else, sticky top bar with brand selector + search + profile.
|
||||
3. **HIG Accessibility-tier legibility** — 18pt body, 500 weight, AAA contrast, 56pt+ tap targets, bold weights throughout. Readable in direct sunlight, tappable with gloves.
|
||||
4. **Read + queue mutations offline** — operators in dead zones can still see data and queue actions (mark ready, mark picked up, change stop status, adjust stock). Optimistic UI with a visible sync status; replays on reconnect with last-write-wins conflict resolution.
|
||||
5. **Production-optimized** — proper cache headers, code-split admin shell, SW cache versioning, no layout shift on font load, Lighthouse PWA + mobile a11y gates in CI.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Redesigning marketing pages (landing, pricing, blog). They stay as-is.
|
||||
- Redesigning the public storefronts. They stay as-is.
|
||||
- Push notifications / Web Push. The SW has a `push` handler scaffolded but it's not in scope; requires VAPID setup and a separate decision.
|
||||
- Wholesale portal mobile redesign. Tabled for a future spec.
|
||||
- Real-time collaboration (websockets / live cursors). Out of scope.
|
||||
- CRDT-style conflict resolution. We use last-write-wins keyed on `updated_at`.
|
||||
|
||||
## Surfaces in scope
|
||||
|
||||
- `/admin` (Dashboard)
|
||||
- `/admin/orders` + `/admin/orders/[id]`
|
||||
- `/admin/stops`
|
||||
- `/admin/products`
|
||||
- The admin shell that wraps all of the above
|
||||
- PWA install, splash, offline page, and SW behavior across the whole app
|
||||
|
||||
---
|
||||
|
||||
## 1. Aesthetic direction & design system
|
||||
|
||||
### Direction: "HIG-First Field Almanac"
|
||||
|
||||
Apple HIG Accessibility-tier legibility is the **floor**; the existing editorial identity (Fraunces + Manrope + Fragment Mono, "Field Almanac" palette already hinted at in `globals.css`) pushed harder is the **ceiling**. The result feels like a high-end tool someone made for themselves — confident, large, warm, with serif labels and serious typography weight. Distinctive without being precious.
|
||||
|
||||
### Type scale (HIG Accessibility-tier)
|
||||
|
||||
| Token | Size / Weight | Use |
|
||||
| ---------------- | ----------------------------------- | -------------------------------------- |
|
||||
| `text-display` | 32pt / Fraunces 600 | Page titles (Dashboard, Orders, etc.) |
|
||||
| `text-h1` | 24pt / Fraunces 600 | Section headers |
|
||||
| `text-h2` | 19pt / Manrope 700 | Card titles, list primary |
|
||||
| `text-body` | 18pt / Manrope 500 | All body text |
|
||||
| `text-label` | 15pt / Manrope 600 / +0.02em | Button text, field labels, status pills|
|
||||
| `text-mono` | 14pt / Fragment Mono 400 | Order IDs, totals, tracking numbers |
|
||||
| `text-meta` | 13pt / Manrope 600 | Timestamps, helper text |
|
||||
|
||||
Body bumped from 16 → 18pt; weight bumped from 400 → 500 for sun legibility.
|
||||
|
||||
### Color tokens (AAA contrast where text appears)
|
||||
|
||||
| Token | Value | Use | Contrast on `bg` / `surface` |
|
||||
| ------------- | --------- | --------------------------------------- | ---------------------------- |
|
||||
| `bg` | `#ffffff` | App background | — |
|
||||
| `surface` | `#faf8f5` | Page background (warm cream) | — |
|
||||
| `surface-2` | `#f5f5f7` | Cards, raised surfaces | — |
|
||||
| `surface-3` | `#e8e8ed` | Hover / pressed / skeletons | — |
|
||||
| `text` | `#1d1d1f` | Primary text | AAA on all 4 surfaces |
|
||||
| `text-muted` | `#424245` | Secondary text | AAA on all 4 surfaces |
|
||||
| `text-faint` | `#5e5e63` | Tertiary / meta | AA on all 4 surfaces (≥ 5.28 : 1) |
|
||||
| `accent` | `#14532d` | Primary actions, brand | AAA on `bg` (9.11), `surface` (8.59), `surface-2` (8.37), `surface-3` (7.46) |
|
||||
| `accent-2` | `#166534` | Hover state (button background) | White text on `accent-2` = 7.13 : 1 (AAA) |
|
||||
| `danger` | `#7f1d1d` | Errors, destructive | AAA on `bg` (10.02), `surface` (9.45), `surface-2` (9.20), `surface-3` (8.20) |
|
||||
| `warning` | `#5e2a04` | Warnings, low stock | AAA on `bg` (11.61), `surface` (10.95), `surface-2` (10.66), `surface-3` (9.51) |
|
||||
| `success` | `#14532d` | Success, completed | Mirrors `accent` |
|
||||
| `info` | `#1e3a8a` | Informational | AAA on all 4 surfaces (8.48–10.36) |
|
||||
|
||||
**Status text on its `-soft` pill background** is also AAA — the dark status text (e.g. `#14532d`) sits on a light tinted fill (e.g. `#dcfce7`) with ≥ 7:1 contrast. The full matrix (5 status × 4 surfaces + 5 status-on-soft + 4 body-text × 4 surfaces + 1 meta × 4 surfaces + 1 button-text + 5 soft-distinct = 35 assertions) is enforced by `src/lib/__tests__/design-tokens.test.ts`.
|
||||
|
||||
`text-faint` is the only token below AAA — used only for de-emphasized meta text where a stronger color would compete with primary content. All token combinations verified by hand calculation; the test suite in Section 5 enforces this in CI.
|
||||
|
||||
### Spacing (8pt grid)
|
||||
|
||||
`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64`
|
||||
|
||||
### Touch targets (HIG Accessibility + gloves)
|
||||
|
||||
- All interactive elements: **56pt min height** (HIG default 44pt → 56pt for gloves)
|
||||
- Tab bar items: 60pt tall, icon 28pt + label 12pt
|
||||
- List row primary action: full-row 72pt tap zone
|
||||
- Bottom sheet drag handle: 56pt wide × 5pt tall
|
||||
- FAB: 64pt diameter
|
||||
- Minimum spacing between adjacent tap targets: 8pt (HIG recommends 16pt for gloves; we use 12pt to keep density reasonable)
|
||||
|
||||
### Motion
|
||||
|
||||
- All motion respects `prefers-reduced-motion`
|
||||
- Card list items stagger in (16ms delay each, max 240ms total) on first mount
|
||||
- Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out)
|
||||
- StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out)
|
||||
- Pull-to-refresh: custom indicator showing last-synced time in mono
|
||||
- Default easing: `cubic-bezier(0.32, 0.72, 0, 1)` (Apple's standard)
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture & data flow
|
||||
|
||||
### Mobile admin shell
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ TopBar: [brand▼] [🔍] [👤] │ ← 56pt, sticky
|
||||
├─────────────────────────────────┤
|
||||
│ │
|
||||
│ Page content │ ← scrollable, max 720pt content width
|
||||
│ (card-stacked) │
|
||||
│ │
|
||||
├─────────────────────────────────┤
|
||||
│ [Home][Orders][Stops][Products] │ ← bottom tab bar, 60pt, fixed
|
||||
│ [More] │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Bottom tab bar** (always visible, thumb zone): Dashboard · Orders · Stops · Products · **More**
|
||||
- **"More" sheet** (drag-up full screen, 90% max-height): Communications, Pickup, Shipping, Reports, Analytics, Settings, etc. — every other admin link, organized into sections.
|
||||
- **TopBar** (sticky): brand selector (left), search button (center, opens full-screen search), profile menu (right).
|
||||
- **No desktop sidebar on mobile** — `AdminSidebar` is hidden below `lg`. Above `lg`, sidebar is unchanged.
|
||||
- A resize-aware `useMediaQuery('(min-width: 1024px)')` swaps between layouts inside `AdminShell`.
|
||||
|
||||
### New shared components
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `src/components/admin/AdminShell.tsx` | Server-component shell that picks nav based on breakpoint |
|
||||
| `src/components/admin/MobileTabBar.tsx` | Client component, fixed bottom, active state from `usePathname()` |
|
||||
| `src/components/admin/MoreSheet.tsx` | Drag-up sheet for secondary nav, uses native `<dialog>` + View Transitions |
|
||||
| `src/components/admin/PageHeader.tsx` | Consistent page title + actions row |
|
||||
| `src/components/admin/StatusPill.tsx` | Color-coded pill (uses new status tokens) |
|
||||
| `src/components/admin/EmptyState.tsx` | Used by all 4 key pages |
|
||||
| `src/components/admin/CardList.tsx` + `CardListItem` | Replaces tables on mobile |
|
||||
| `src/components/admin/StickyActionBar.tsx` | Primary action pinned to bottom of detail screens |
|
||||
| `src/components/admin/OfflineBanner.tsx` | Top-of-screen offline indicator with pending sync count |
|
||||
|
||||
### Routing
|
||||
|
||||
No URL changes. The same `/admin/orders`, `/admin/stops`, `/admin/products` routes serve both layouts; the shell is purely presentational. Server components stay server components — the mobile shell is a thin client wrapper around them.
|
||||
|
||||
### Data flow
|
||||
|
||||
| Page | Server source | Mobile client behavior |
|
||||
| ---------------- | ------------------------------------------------------ | ------------------------------------------------------------- |
|
||||
| Dashboard | `get_dashboard_summary(brand_id)` (new RPC) | Cards, 2-col grid on tablet, 1-col on phone |
|
||||
| Orders list | `get_orders_for_brand(brand_id, …)` (existing) | CardList with status pill, customer, total, time |
|
||||
| Order detail | `get_order_detail(order_id)` (existing) | Header card + line items + timeline + StickyActionBar |
|
||||
| Stops | `get_stops_for_brand(brand_id, …)` (existing) | CardList with time, address, status, "navigate" action |
|
||||
| Products | `get_products_for_brand(brand_id, …)` (existing) | CardList with image, name, price, stock |
|
||||
|
||||
### Offline mutation queue
|
||||
|
||||
- `src/lib/offline/queue.ts` — IndexedDB store keyed by `clientActionId`
|
||||
- `src/lib/offline/sync.ts` — replays queued actions on `online` event with exponential backoff
|
||||
- Server actions stay the source of truth; the queue records `actionName + payload + clientActionId + createdAt` and posts them when online
|
||||
- Each queued action shows in the UI with a "pending / synced / conflict" badge in `StickyActionBar`
|
||||
- `OfflineBanner` shows when `navigator.onLine === false` and surfaces pending sync count
|
||||
- **Conflict resolution:** last-write-wins keyed on `updated_at` returned by the server. After each sync, the client refetches the affected entity and merges.
|
||||
- **Backoff:** 1s, 4s, 16s, 60s, 5min; give up after 5 attempts, surface a "Manual retry" button on the conflicting item.
|
||||
- **Idempotency:** every queued action carries a `clientActionId` (uuid v4 generated at enqueue time). The server's RPC layer is updated to accept and dedupe on this id.
|
||||
|
||||
### New RPC (for dashboard)
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION get_dashboard_summary(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'orders_today', (SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE),
|
||||
'revenue_today', (SELECT COALESCE(SUM(total), 0) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE AND status NOT IN ('cancelled')),
|
||||
'pending_fulfillment', (SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND status IN ('placed', 'ready')),
|
||||
'stops_today', (SELECT COUNT(*) FROM stops WHERE brand_id = p_brand_id AND scheduled_at::date = CURRENT_DATE),
|
||||
'orders_last_7_days', (
|
||||
SELECT jsonb_agg(jsonb_build_object('date', d::date, 'count', COALESCE(o.cnt, 0)))
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') d
|
||||
LEFT JOIN (
|
||||
SELECT created_at::date AS day, COUNT(*) AS cnt
|
||||
FROM orders
|
||||
WHERE brand_id = p_brand_id AND created_at >= CURRENT_DATE - INTERVAL '7 days'
|
||||
GROUP BY 1
|
||||
) o ON o.day = d::date
|
||||
)
|
||||
) INTO result;
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. PWA, offline, and production hardening
|
||||
|
||||
### PWA — what's broken and what we're fixing
|
||||
|
||||
| Issue today | Fix |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| `public/icons/` doesn't exist | Generate all 8 sizes (72–512) PNG + maskable + badge + 3 shortcut icons |
|
||||
| `public/screenshots/` doesn't exist | Generate `dashboard.png` (1280×720) and `mobile-storefront.png` (390×844) |
|
||||
| `apple-touch-icon.png` missing | Generate 180×180 + 167×167 + 152×152 |
|
||||
| `favicon.ico` missing | Generate from SVG |
|
||||
| `sw.js` references `/offline` page that doesn't exist | Create `public/offline.html` (clean cream HIG-style page) |
|
||||
| `sw.js` references `/og-default.jpg` (only .svg exists)| Update ref to `/og-default.svg` |
|
||||
| `registerServiceWorker()` exported but never called | Call it from `src/components/Providers.tsx` (client side) |
|
||||
| `PWAInstallPrompt` never mounted | Mount in `src/app/admin/layout.tsx` (admin is the installable surface) |
|
||||
| `themeColor` mismatch (`#0a0a0a` vs `#1a4d2e`) | Standardize on `#166534` (forest-800) |
|
||||
| Missing PWA meta tags | Add `apple-mobile-web-app-capable`, `application-name`, `mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style` |
|
||||
| `viewport.maximumScale = 5` but no `viewportFit: cover`| Add `viewportFit: 'cover'` for notched devices |
|
||||
|
||||
### Service worker rewrite
|
||||
|
||||
Replace `public/sw.js` with a versioned, two-cache SW:
|
||||
|
||||
- **App shell cache** (e.g. `rc-shell-v3`): pre-caches `/`, `/admin`, `/manifest.json`, fonts, icons, offline page — install-time `cache.addAll(…)`.
|
||||
- **Data cache** (e.g. `rc-data-v3`): stale-while-revalidate for `/api/orders`, `/api/stops`, `/api/products`, `/api/dashboard` — read offline, refetch in background.
|
||||
- **Cache strategies:**
|
||||
- Navigations → network-first, fall back to cache, fall back to `/offline.html`
|
||||
- Static assets (`/_next/static/*`, `/icons/*`, `/screenshots/*`) → cache-first
|
||||
- API GETs (`/api/*` non-mutation) → stale-while-revalidate
|
||||
- API mutations (`POST`/`PATCH`/`DELETE`) → never cached, pass-through; offline behavior handled by the client queue
|
||||
- **Versioning:** `CACHE_NAME` bumped on each release; activate handler deletes old caches; `skipWaiting()` + `clients.claim()` to roll out updates fast.
|
||||
- **Logging:** all `console.log` calls guarded by `process.env.NODE_ENV !== 'production'`.
|
||||
|
||||
### Production hardening
|
||||
|
||||
- `next.config.ts` — add `headers()` for `Cache-Control: public, max-age=31536000, immutable` on `/_next/static/*` and `/icons/*`
|
||||
- Font preloading: `next/font` already inlines the CSS — verify the `display: swap` strategy stays
|
||||
- Image optimization: existing `next/image` config; add `priority` to the admin logo in the top bar
|
||||
- Code splitting: admin layout stays in its own chunk; mobile shell is a dynamic import gated on viewport
|
||||
- No layout shift on font load: `next/font` reserves metric-based space
|
||||
|
||||
### Files touched in this section
|
||||
|
||||
- `public/manifest.json` (theme_color + new screenshot paths)
|
||||
- `public/sw.js` (full rewrite)
|
||||
- `public/offline.html` (new)
|
||||
- `public/icons/*.png` (8 + maskable + badge + 3 shortcuts = 13 new files)
|
||||
- `public/screenshots/*.png` (2 new)
|
||||
- `public/apple-touch-icon.png` + variants (3 new)
|
||||
- `public/favicon.ico` (1 new)
|
||||
- `src/app/layout.tsx` (add PWA meta + register SW via Providers)
|
||||
- `src/components/Providers.tsx` (call `registerServiceWorker()` on mount)
|
||||
- `src/app/admin/layout.tsx` (mount `PWAInstallPrompt`)
|
||||
- `next.config.ts` (cache headers for static assets)
|
||||
|
||||
---
|
||||
|
||||
## 4. Page-by-page layouts
|
||||
|
||||
All pages share: top bar, page header (title + primary action), card-stacked body (no tables on phones), bottom tab bar. The desktop layout is unchanged above `lg`.
|
||||
|
||||
### Dashboard (`/admin`)
|
||||
|
||||
- **Page header:** "Today" (Fraunces 32pt) + date in mono + brand selector (in top bar)
|
||||
- **Stat row** (2×2 grid on phone, 4×1 on tablet+): Orders Today · Revenue · Pending Fulfillment · Stops Today — each card is `surface-2` with a big Fraunces number, Manrope label, and a 7-day sparkline in mono
|
||||
- **"Needs you" card** (only if pending actions > 0): top-of-page warning card with count + "View all" CTA
|
||||
- **Today's stops timeline:** vertical timeline of upcoming stops, each row tappable
|
||||
- **Aesthetic:** calm, editorial, "open the laptop on the couch" feel. Numbers in Fraunces do the talking.
|
||||
|
||||
### Orders list (`/admin/orders`)
|
||||
|
||||
- **Page header:** "Orders" + filter button (opens bottom sheet) + "+ New order"
|
||||
- **Filter sheet:** status, date range, customer, stop — drag-up sheet, sticky apply bar
|
||||
- **Card list** (one per order):
|
||||
- Top row: status pill (left) · order # in mono · time-ago (right)
|
||||
- Middle: customer name (Manrope 700 19pt) + item count ("4 items")
|
||||
- Bottom: total (Fraunces 22pt) · arrow chevron
|
||||
- Whole row: 72pt tap zone
|
||||
- **Empty state:** "No orders yet" + illustration + "+ New order" CTA
|
||||
- **Pull-to-refresh** at the top
|
||||
|
||||
### Order detail (`/admin/orders/[id]`)
|
||||
|
||||
- **Header card:** customer name (Fraunces 28pt), phone + email (tap-to-call / tap-to-email), order # in mono, status pill
|
||||
- **Line items card:** each item is a sub-row — image (56pt square), name, qty × price, fulfillment icon (pickup vs ship)
|
||||
- **Fulfillment timeline card:** horizontal stepper (Placed → Ready → Picked up) with timestamps in mono
|
||||
- **Customer notes card** (if present): quoted, italic
|
||||
- **StickyActionBar** (bottom, above tab bar): primary action changes by status:
|
||||
- Placed → "Mark Ready" (forest-800 fill, full width, 56pt)
|
||||
- Ready → "Mark Picked Up" (forest-800 fill)
|
||||
- Picked up → "View Receipt" (outlined)
|
||||
- **Secondary actions** (refund, edit, contact customer) live in a top-right "•••" menu
|
||||
- **Offline pending badge:** if the order was just updated offline, the action bar shows "Pending sync" with a tiny spinner until the queue replays
|
||||
|
||||
### Stops (`/admin/stops`)
|
||||
|
||||
- **Page header:** "Stops" + date picker (large, tappable) + "+ New stop"
|
||||
- **Time-grouped sections:** "Morning" / "Afternoon" / "Evening" with sticky section headers (Manrope 700 15pt, surface-50 bg, 8pt vertical padding)
|
||||
- **Stop card:**
|
||||
- Time (Fraunces 28pt, left) · address (Manrope 600 17pt) · customer count
|
||||
- Status pill + "Navigate" icon button (opens maps app via `navigator.share` fallback to URL)
|
||||
- Inline status changer: tap status pill → bottom sheet with status options
|
||||
- Whole row: 88pt to accommodate time + address comfortably
|
||||
- **Empty state:** "No stops scheduled" + "+ New stop"
|
||||
|
||||
### Products (`/admin/products`)
|
||||
|
||||
- **Page header:** "Products" + search input (always visible, prominent) + "+ New product"
|
||||
- **Card list:**
|
||||
- Image (64pt square, rounded-2xl, object-cover) · name (Manrope 700 17pt) · price (Fraunces 20pt)
|
||||
- Stock row: "142 in stock" or "⚠ 8 left" or "✕ Out of stock" — color + icon, never color alone
|
||||
- "Adjust stock" via long-press → bottom sheet with +/− stepper
|
||||
- **Filter chips above list:** All · In stock · Low · Out · Hidden
|
||||
- **Empty state:** "No products" + "+ New product"
|
||||
|
||||
### Motion (per page)
|
||||
|
||||
- Card list items stagger in (16ms delay each, max 240ms total) on first mount
|
||||
- Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out)
|
||||
- Pull-to-refresh: custom indicator showing last-synced time
|
||||
- StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out)
|
||||
- All motion respects `prefers-reduced-motion`
|
||||
|
||||
### Aesthetic summary
|
||||
|
||||
Editorial-meets-field. Fraunces for numbers, prices, and the few serif headlines; Manrope for everything else; Fragment Mono for IDs, totals, timestamps. Warm cream surfaces, deeply saturated status colors, generous breathing room. Reads like a high-end tool someone made for themselves — not a generic admin template.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing & verification
|
||||
|
||||
### Unit tests (Vitest)
|
||||
|
||||
- `src/lib/offline/queue.test.ts` — enqueue, dequeue, persist across page reload, idempotency on `clientActionId`
|
||||
- `src/lib/offline/sync.test.ts` — replays on `online` event, exponential backoff (1s, 4s, 16s, 60s, 5min, give up at 5 attempts), conflict surfacing
|
||||
- `src/lib/format-date.test.ts` — extend for new relative formats ("just now", "5m ago", "2h ago", "yesterday")
|
||||
- `src/lib/__tests__/design-tokens.test.ts` — asserts all token combinations meet WCAG contrast on the surfaces they actually render on (body text on all 4 surfaces at AAA; status text on `bg` + `surface` + their `-soft` pill backgrounds at AAA; `text-faint` on all 4 surfaces at AA; `accent-2` as a button background with white text at AAA); fails CI if a new color regresses
|
||||
|
||||
### Component tests (Vitest + Testing Library)
|
||||
|
||||
- `MobileTabBar` — active state, more-sheet trigger, keyboard nav
|
||||
- `StickyActionBar` — pending badge, conflict state
|
||||
- `OfflineBanner` — appears on `offline` event, hidden on `online`
|
||||
- `StatusPill` — every status × every color combination renders the right aria-label
|
||||
- `MoreSheet` — opens via More button, traps focus, closes on Escape and backdrop click, restores focus on close
|
||||
|
||||
**UI primitives:** No dialog/sheet library (vaul, radix, headlessui) is currently in the project. `MoreSheet` and the various bottom sheets (filter, status changer, stock stepper) use the native `<dialog>` element with `showModal()` + the View Transitions API for open/close animation. This is a deliberate choice to keep the bundle small for the PWA. The native `<dialog>` is well-supported in Safari 15.4+, Chrome 37+, and Firefox 98+ — all versions we target.
|
||||
|
||||
**Pull-to-refresh:** Custom implementation using pointer events on a sentinel element at the top of each list. We avoid adding a third-party pull-to-refresh library to keep the bundle small. The custom implementation is ~80 lines, respects `overscroll-behavior: contain`, and falls back to no-op if the user has indicated motion sensitivity.
|
||||
|
||||
**Tap-to-call / tap-to-email:** Implemented with explicit `tel:` and `mailto:` URL schemes on `<a>` tags (not buttons that call `window.location`). This ensures the OS handles the routing (e.g., FaceTime prompts on iOS, Gmail/Outlook chooser on Android) and is accessible to screen readers out of the box.
|
||||
|
||||
**Color contrast test exclusions:** `text-faint` is the only token below WCAG AAA (it passes AA at ≥ 5.28:1). The test in `src/lib/__tests__/design-tokens.test.ts` will assert AAA (7:1) for every token combination *except* `text-faint`, which is tested at AA (4.5:1). This is documented in the test file as an intentional exception.
|
||||
|
||||
### E2E (Playwright)
|
||||
|
||||
New spec: `tests/mobile-admin/pwa-install.spec.ts`
|
||||
- Loads `/admin` on iPhone 13 viewport
|
||||
- Asserts manifest link present, manifest is valid JSON, all icons return 200
|
||||
- Asserts `apple-touch-icon` link present
|
||||
- Asserts service worker registers successfully (via `navigator.serviceWorker.ready`)
|
||||
|
||||
New spec: `tests/mobile-admin/orders-list.spec.ts`
|
||||
- Loads `/admin/orders` on iPhone 13 viewport
|
||||
- Asserts no horizontal scroll (page width ≤ viewport)
|
||||
- Asserts every interactive element has touch target ≥ 48pt
|
||||
- Asserts all text passes axe-core
|
||||
|
||||
New spec: `tests/mobile-admin/offline-queue.spec.ts`
|
||||
- Loads `/admin/orders/<id>` on iPhone 13 viewport
|
||||
- Goes offline (Playwright `context.setOffline(true)`)
|
||||
- Clicks "Mark Ready"
|
||||
- Asserts the optimistic UI updates
|
||||
- Asserts the action bar shows "Pending sync" badge
|
||||
- Goes back online
|
||||
- Asserts the badge clears within 5s and the server has the new status
|
||||
|
||||
### Visual regression (Playwright `toHaveScreenshot`)
|
||||
|
||||
- All 4 key page screens at 390×844 (iPhone 13)
|
||||
- All 4 key page screens at 1024×768 (iPad)
|
||||
- MobileTabBar — light + dark
|
||||
- OfflineBanner — visible, hidden
|
||||
|
||||
### Manual QA checklist (added to PR template)
|
||||
|
||||
- [ ] Read every new screen in direct sunlight (or under a 5000K desk lamp at full brightness)
|
||||
- [ ] Tap every interactive element while wearing work gloves
|
||||
- [ ] Verify install-to-home-screen on a real iPhone (Safari → Share → Add to Home Screen) and confirm the splash + app icon are correct
|
||||
- [ ] Toggle airplane mode and exercise: orders list, order detail, stops, products, mark-ready, mark-picked-up, stop status change, stock adjust
|
||||
- [ ] Run `npx lighthouse http://localhost:3000/admin --preset=mobile --view` and confirm Performance ≥ 90, Accessibility ≥ 95, PWA checks pass
|
||||
|
||||
### Lighthouse / PWA gate (CI)
|
||||
|
||||
- Add a CI job (`.gitea/workflows/pwa-audit.yml`) that runs `lighthouse-ci` against the preview URL on every PR
|
||||
- PWA category must pass (installable, manifest valid, service worker registered, themed)
|
||||
- Mobile accessibility score must be ≥ 95
|
||||
|
||||
### Documentation
|
||||
|
||||
- `docs/pwa-testing.md` — how to manually test PWA + offline on a real device
|
||||
- `docs/admin-mobile.md` — design rationale + token reference for the team
|
||||
|
||||
### Files touched in this section
|
||||
|
||||
~8 new test files, 1 CI workflow, 2 docs, plus assertions added to `playwright.config.ts` to include the `mobile-admin` project at iPhone 13 viewport by default.
|
||||
|
||||
---
|
||||
|
||||
## File inventory (consolidated)
|
||||
|
||||
### New files
|
||||
|
||||
- `public/icons/icon-{72,96,128,144,152,192,384,512}x{72,96,128,144,152,192,384,512}.png` (8)
|
||||
- `public/icons/icon-maskable-512x512.png` (1)
|
||||
- `public/icons/badge-72x72.png` (1)
|
||||
- `public/icons/{orders,products,stops}-shortcut.png` (3)
|
||||
- `public/screenshots/dashboard.png` (1)
|
||||
- `public/screenshots/mobile-storefront.png` (1)
|
||||
- `public/apple-touch-icon.png` (180×180), `public/apple-touch-icon-167.png`, `public/apple-touch-icon-152.png` (3)
|
||||
- `public/favicon.ico` (1)
|
||||
- `public/offline.html` (1)
|
||||
- `src/components/admin/AdminShell.tsx`
|
||||
- `src/components/admin/MobileTabBar.tsx`
|
||||
- `src/components/admin/MoreSheet.tsx`
|
||||
- `src/components/admin/PageHeader.tsx`
|
||||
- `src/components/admin/StatusPill.tsx`
|
||||
- `src/components/admin/EmptyState.tsx`
|
||||
- `src/components/admin/CardList.tsx`
|
||||
- `src/components/admin/StickyActionBar.tsx`
|
||||
- `src/components/admin/OfflineBanner.tsx`
|
||||
- `src/lib/offline/queue.ts`
|
||||
- `src/lib/offline/sync.ts`
|
||||
- `src/lib/offline/db.ts` (IndexedDB wrapper)
|
||||
- `src/lib/__tests__/design-tokens.test.ts`
|
||||
- `src/lib/offline/queue.test.ts`
|
||||
- `src/lib/offline/sync.test.ts`
|
||||
- `tests/mobile-admin/pwa-install.spec.ts`
|
||||
- `tests/mobile-admin/orders-list.spec.ts`
|
||||
- `tests/mobile-admin/offline-queue.spec.ts`
|
||||
- `.gitea/workflows/pwa-audit.yml`
|
||||
- `docs/pwa-testing.md`
|
||||
- `docs/admin-mobile.md`
|
||||
- `db/migrations/NNN_dashboard_summary_rpc.sql` (new RPC for dashboard)
|
||||
|
||||
### Modified files
|
||||
|
||||
- `public/manifest.json` (theme_color, screenshot paths)
|
||||
- `public/sw.js` (full rewrite)
|
||||
- `src/app/layout.tsx` (PWA meta tags, viewport.fit, themeColor)
|
||||
- `src/components/Providers.tsx` (call `registerServiceWorker()`)
|
||||
- `src/app/admin/layout.tsx` (mount `PWAInstallPrompt` + `OfflineBanner`, swap sidebar for `AdminShell`)
|
||||
- `src/app/admin/v2/page.tsx` (Dashboard card layout — new v2 route)
|
||||
- `src/app/admin/v2/orders/page.tsx` (CardList — new v2 route)
|
||||
- `src/app/admin/v2/orders/[id]/page.tsx` (card layout + StickyActionBar + offline-queue integration — new v2 route)
|
||||
- `src/app/admin/v2/stops/page.tsx` (time-grouped card list — new v2 route)
|
||||
- `src/app/admin/v2/products/page.tsx` (image-forward card list — new v2 route)
|
||||
- `next.config.ts` (cache headers for static assets; cutover redirects in PRs 6 and 8)
|
||||
- `playwright.config.ts` (add `mobile-admin` project at iPhone 13 viewport)
|
||||
- `src/lib/pwa.ts` (small fixes: `load` listener guard, dev-only logging)
|
||||
- `src/app/page.tsx` (favicon ref fix)
|
||||
|
||||
Note: the v1 admin pages (`src/app/admin/page.tsx`, `src/app/admin/orders/page.tsx`, etc.) are **not** modified by PRs 1–5. They continue to serve the existing desktop layout until the cutover PRs redirect their routes to v2. After cutover, the v1 files are deleted in a follow-up commit.
|
||||
|
||||
---
|
||||
|
||||
## Open questions (deferred)
|
||||
|
||||
- **Web Push notifications** — SW has a handler scaffolded, but enabling real pushes requires VAPID keys, a push service, and a UX decision. Tabled for a follow-up spec.
|
||||
- **Wholesale portal mobile** — out of scope here; would benefit from the same design system once the admin is shipped.
|
||||
- **App Store / Play Store packaging** — Capacitor / TWA is the obvious next step once the PWA is solid. Not in this spec.
|
||||
- **Image upload from camera** — products/stops could use this; separate spec.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
1. **The existing admin pages have a lot of bespoke UI** (drag-and-drop, complex tables, modals). Some of it may need to be simplified for the mobile-first treatment, not just wrapped. We'll discover this during implementation; the design language and shared components are designed to absorb it.
|
||||
2. **The IndexedDB queue + idempotency requires server-side support** (`clientActionId` parameter on the relevant RPCs). The migration for the new RPCs is in scope; the RPC changes for idempotency are flagged here so they don't surprise us.
|
||||
3. **iOS PWA limitations** — no push, no background sync (until iOS 16.4+ and even then limited), no install prompt via API (we use the manual `beforeinstallprompt` only, plus the iOS "Add to Home Screen" instructions in our install prompt copy). We document these limitations in the install prompt UI.
|
||||
4. **Outdoor screen testing is subjective.** The 5000K lamp proxy won't catch every case. Real device testing in sunlight is required for sign-off.
|
||||
|
||||
---
|
||||
|
||||
## Rollout plan
|
||||
|
||||
> **Note on feature flags:** The project has a `src/lib/feature-flags.ts` system, but it's for **brand-scoped add-on features** (Harvest Reach, Square Sync, etc.), not for app-wide UI rollouts. There is no general-purpose feature flag system in this codebase. We will use a **route-based dual-deployment** strategy for rollout:
|
||||
>
|
||||
> - PRs 1–2 (design system + PWA scaffolding) are non-breaking; they add the new infrastructure without changing existing routes.
|
||||
> - PRs 3–5 (Orders, Stops, Products) ship the new mobile-first versions at **`/admin/v2/orders`**, **`/admin/v2/stops`**, **`/admin/v2/products`**, and **`/admin/v2/orders/[id]`** initially. The old `/admin/orders` etc. routes continue to work unchanged. Internal team dogfoods at `/admin/v2/*` for 1–2 weeks.
|
||||
> - PR 6 cuts over: a redirect in `next.config.ts` sends `/admin/orders*` → `/admin/v2/orders*`, etc. The old routes are removed in a follow-up commit.
|
||||
> - PR 7 (Dashboard at `/admin/v2`) follows the same pattern.
|
||||
>
|
||||
> This avoids the need to introduce a feature flag system, which would be a separate piece of work.
|
||||
|
||||
1. **PR 1 — Design system + admin shell** — type scale, color tokens, AdminShell, MobileTabBar, MoreSheet, offline queue + sync. No page changes yet. Lighthouse PWA gate added.
|
||||
2. **PR 2 — PWA manifest + SW + icons + offline page** — PWA installable, splash, themed status bar. All tests pass.
|
||||
3. **PR 3 — Orders v2 (list + detail)** at `/admin/v2/orders/*` — first page on the new design language. Validates the components in real use.
|
||||
4. **PR 4 — Stops v2** at `/admin/v2/stops`
|
||||
5. **PR 5 — Products v2** at `/admin/v2/products`
|
||||
6. **PR 6 — Cutover Orders + Stops + Products** — redirects in `next.config.ts`; old routes removed in a follow-up commit
|
||||
7. **PR 7 — Dashboard v2** at `/admin/v2` (depends on the new `get_dashboard_summary` RPC migration)
|
||||
8. **PR 8 — Dashboard cutover** — redirect `/admin` → `/admin/v2`; remove old dashboard
|
||||
|
||||
Each PR is independently shippable and reversible. The desktop layout is never affected until cutover PRs land.
|
||||
|
||||
---
|
||||
|
||||
## Definition of done
|
||||
|
||||
- [ ] All 4 key pages ship the mobile-first treatment
|
||||
- [ ] PWA installs on iOS Safari and Android Chrome
|
||||
- [ ] Service worker pre-caches the app shell, swr-caches the data, falls back to `/offline` on navigation
|
||||
- [ ] Offline mutation queue replays actions on reconnect with no data loss
|
||||
- [ ] All interactive elements ≥ 48pt (aim 56pt) on mobile
|
||||
- [ ] All text passes WCAG AAA (7:1) against its background
|
||||
- [ ] Lighthouse mobile audit: Performance ≥ 90, Accessibility ≥ 95, PWA passes
|
||||
- [ ] No horizontal scroll on any of the 4 key pages at iPhone 13 viewport
|
||||
- [ ] `prefers-reduced-motion` honored
|
||||
- [ ] All Playwright specs (PWA install, orders list, offline queue) pass in CI
|
||||
- [ ] Visual regression baselines committed for all 4 key pages × 2 viewports
|
||||
- [ ] Real-device install + offline testing documented and signed off
|
||||
@@ -0,0 +1,135 @@
|
||||
# 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
|
||||
@@ -0,0 +1,337 @@
|
||||
# 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 shared `pg` Pool (see `src/lib/db.ts`).
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Drizzle Kit config. Used by `drizzle-kit generate` / `drizzle-kit push`
|
||||
* for future migrations. The schema in `db/migrations/0001_init.sql` is
|
||||
* the source of truth for v1; subsequent migrations can be generated
|
||||
* from changes to `db/schema/*.ts` and committed alongside the SQL.
|
||||
*/
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./db/schema/index.ts",
|
||||
out: "./db/migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
@@ -12,6 +12,9 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Ignore legacy .js scripts that use CommonJS
|
||||
"scripts/**",
|
||||
"fix-agents.js",
|
||||
]),
|
||||
// Allow setState in useEffect for PWA prompts and client-side state initialization
|
||||
{
|
||||
@@ -20,6 +23,14 @@ const eslintConfig = defineConfig([
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
},
|
||||
},
|
||||
// Relax some rules for legacy code
|
||||
{
|
||||
files: ["db/**", "scripts/**"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import openpyxl
|
||||
path = "/home/coder/dev/x1/kyle/route_commerce-main/Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
|
||||
wb = openpyxl.load_workbook(path, data_only=True)
|
||||
for name in wb.sheetnames:
|
||||
ws = wb[name]
|
||||
print(f"=== SHEET: {name} ({ws.max_row} rows x {ws.max_column} cols) ===")
|
||||
for row in ws.iter_rows(values_only=False):
|
||||
for cell in row:
|
||||
if cell.value is not None:
|
||||
v = str(cell.value)
|
||||
if len(v) > 200:
|
||||
v = v[:200] + "..."
|
||||
print(f" {cell.coordinate}: {v!r}")
|
||||
print()
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"url": [
|
||||
"http://localhost:3000/admin/v2/orders",
|
||||
"http://localhost:3000/admin/v2/stops",
|
||||
"http://localhost:3000/admin/v2/products"
|
||||
],
|
||||
"numberOfRuns": 1,
|
||||
"settings": {
|
||||
"preset": "mobile",
|
||||
"emulatedFormFactor": "mobile"
|
||||
}
|
||||
},
|
||||
"assert": {
|
||||
"assertions": {
|
||||
"categories:performance": ["error", { "minScore": 0.9 }],
|
||||
"categories:accessibility": ["error", { "minScore": 0.95 }],
|
||||
"categories:pwa": "error",
|
||||
"installable-manifest": "error",
|
||||
"service-worker": "error",
|
||||
"themed-omnibox": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const response = NextResponse.next({ request });
|
||||
|
||||
// ── Dev session bypass (enabled in all envs for demo) ──────────────
|
||||
// Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
|
||||
const devSession = request.cookies.get("dev_session")?.value;
|
||||
const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee";
|
||||
const rcAuthUid = request.cookies.get("rc_auth_uid")?.value;
|
||||
|
||||
let authUid: string | null = null;
|
||||
|
||||
if (isDevMode) {
|
||||
// Dev session only valid in development
|
||||
authUid = DEV_UID;
|
||||
} else if (rcAuthUid) {
|
||||
// rc_auth_uid is set by /api/login — treat as authenticated
|
||||
authUid = rcAuthUid;
|
||||
}
|
||||
// No rc_auth_uid in production → authUid stays null → redirect to /login
|
||||
|
||||
const isAdmin = request.nextUrl.pathname.startsWith("/admin");
|
||||
const isLogin = request.nextUrl.pathname === "/login";
|
||||
|
||||
if (isAdmin && !authUid) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
// Auto-login for demo: no Supabase configured, no auth cookie present
|
||||
if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/admin";
|
||||
url.searchParams.set("demo", "1");
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.set("dev_session", "platform_admin", {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24,
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
if (isLogin && authUid) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/admin";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/admin/:path*",
|
||||
"/admin",
|
||||
"/login",
|
||||
],
|
||||
};
|
||||
@@ -1,16 +1,27 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
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
|
||||
reactStrictMode: true,
|
||||
|
||||
// Optimize images
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.supabase.co",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
@@ -71,18 +82,56 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/_next/static/:path*",
|
||||
headers: [
|
||||
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/icons/:path*",
|
||||
headers: [
|
||||
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/screenshots/:path*",
|
||||
headers: [
|
||||
{ key: "Cache-Control", value: "public, max-age=604800" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// Redirects
|
||||
async redirects() {
|
||||
return [
|
||||
// Redirect old paths if needed
|
||||
// {
|
||||
// source: '/old-path',
|
||||
// destination: '/new-path',
|
||||
// permanent: true,
|
||||
// },
|
||||
// Cutover (PR 6, Task 6.1): mobile-first admin lives at /admin/v2/*.
|
||||
// Redirect the v1 list/detail paths to their v2 equivalents.
|
||||
// Use `permanent: false` (307) so we can revert easily if a
|
||||
// regression is caught in monitoring before the v1 files are
|
||||
// removed in Task 6.2.
|
||||
// PR 8, Task 8.1: redirect the bare /admin path to the v2 dashboard.
|
||||
// The dashboard itself still lives at /admin (as the v1 page)
|
||||
// until Task 8.2 removes it after a 3-day monitoring window —
|
||||
// this redirect just points the v1 entry point at v2.
|
||||
{ source: "/admin", destination: "/admin/v2", permanent: false },
|
||||
{ source: "/admin/orders", destination: "/admin/v2/orders", permanent: false },
|
||||
{ source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false },
|
||||
{ source: "/admin/stops", destination: "/admin/v2/stops", permanent: false },
|
||||
{ source: "/admin/products", destination: "/admin/v2/products", permanent: false },
|
||||
// v1 admin pages that were removed when the supabase shim was
|
||||
// deleted. There are no v2 equivalents yet (reports / taxes /
|
||||
// settings sub-pages are still TBD on the v2 surface), so they
|
||||
// redirect to the v2 dashboard for now. Once those modules ship
|
||||
// on v2, the redirects can be tightened to point at the new
|
||||
// pages.
|
||||
{ source: "/admin/products/:id", destination: "/admin/v2/products", permanent: false },
|
||||
{ source: "/admin/reports", destination: "/admin/v2", permanent: false },
|
||||
{ source: "/admin/taxes", destination: "/admin/v2", permanent: false },
|
||||
{ source: "/admin/settings/shipping", destination: "/admin/settings", permanent: false },
|
||||
{ source: "/admin/settings/integrations", destination: "/admin/settings", permanent: false },
|
||||
{ source: "/admin/settings/billing", destination: "/admin/settings", permanent: false },
|
||||
];
|
||||
},
|
||||
|
||||
@@ -97,6 +146,11 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
// Enable optimizePackageImports for better bundle size
|
||||
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
|
||||
|
||||
|
After Width: | Height: | Size: 352 KiB |
@@ -1,33 +1,49 @@
|
||||
{
|
||||
"name": "route-commerce-platform",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0",
|
||||
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"migrate": "node supabase/push-migrations.js",
|
||||
"migrate:one": "node supabase/push-migrations.js",
|
||||
"migrate": "node scripts/migrate.js",
|
||||
"migrate:one": "node scripts/migrate.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",
|
||||
"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}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.96.0",
|
||||
"@clerk/nextjs": "^7.4.2",
|
||||
"@aws-sdk/client-s3": "^3.1064.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1064.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@neondatabase/auth": "^0.4.2-beta",
|
||||
"@neondatabase/serverless": "^1.1.0",
|
||||
"@sentry/nextjs": "^10.55.0",
|
||||
"@supabase/ssr": "^0.10.2",
|
||||
"@supabase/supabase-js": "^2.105.3",
|
||||
"@stripe/react-stripe-js": "^6.6.0",
|
||||
"@stripe/stripe-js": "^9.7.0",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.38.0",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"exceljs": "^4.4.0",
|
||||
"framer-motion": "^12.40.0",
|
||||
"gsap": "^3.15.0",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "^16.2.6",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^6.37.0",
|
||||
"papaparse": "^5.5.3",
|
||||
@@ -44,21 +60,30 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.5",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^25.0.1",
|
||||
"pg": "^8.20.0",
|
||||
"playwright": "^1.59.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"overrides": "{}"
|
||||
}
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
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({
|
||||
testDir: "./tests",
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
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",
|
||||
use: {
|
||||
baseURL: "https://route-commerce-platform.vercel.app",
|
||||
baseURL: LOCAL_BASE,
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "local",
|
||||
use: { ...devices["Desktop Chrome"], baseURL: LOCAL_BASE },
|
||||
},
|
||||
{
|
||||
name: "production",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: "https://route-commerce-platform.vercel.app",
|
||||
},
|
||||
// `PLAYWRIGHT_PROD=1 npx playwright test` to run against the live site.
|
||||
testMatch: /.*\.prod\.spec\.ts$/,
|
||||
use: { ...devices["Desktop Chrome"], baseURL: PROD_BASE },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 590 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -2,101 +2,33 @@
|
||||
"name": "Route Commerce",
|
||||
"short_name": "Route Commerce",
|
||||
"description": "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution",
|
||||
"start_url": "/",
|
||||
"start_url": "/admin",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#faf8f5",
|
||||
"theme_color": "#1a4d2e",
|
||||
"theme_color": "#166534",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
{ "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"categories": ["business", "productivity"],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "View Orders",
|
||||
"short_name": "Orders",
|
||||
"description": "View and manage orders",
|
||||
"url": "/admin/orders",
|
||||
"icons": [{ "src": "/icons/orders-shortcut.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "Add Product",
|
||||
"short_name": "Add Product",
|
||||
"description": "Add a new product",
|
||||
"url": "/admin/products/new",
|
||||
"icons": [{ "src": "/icons/products-shortcut.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "Create Stop",
|
||||
"short_name": "Create Stop",
|
||||
"description": "Schedule a new pickup stop",
|
||||
"url": "/admin/stops/new",
|
||||
"icons": [{ "src": "/icons/stops-shortcut.png", "sizes": "96x96" }]
|
||||
}
|
||||
{ "name": "View Orders", "short_name": "Orders", "url": "/admin/v2/orders", "icons": [{ "src": "/icons/orders-shortcut.png", "sizes": "96x96" }] },
|
||||
{ "name": "Add Product", "short_name": "Add Product", "url": "/admin/v2/products/new", "icons": [{ "src": "/icons/products-shortcut.png", "sizes": "96x96" }] },
|
||||
{ "name": "Create Stop", "short_name": "Create Stop", "url": "/admin/v2/stops/new", "icons": [{ "src": "/icons/stops-shortcut.png", "sizes": "96x96" }] }
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshots/dashboard.png",
|
||||
"sizes": "1280x720",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide",
|
||||
"label": "Admin Dashboard"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/mobile-storefront.png",
|
||||
"sizes": "390x844",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow",
|
||||
"label": "Mobile Storefront"
|
||||
}
|
||||
{ "src": "/screenshots/dashboard.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide", "label": "Admin Dashboard" },
|
||||
{ "src": "/screenshots/mobile-storefront.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow", "label": "Mobile Storefront" }
|
||||
],
|
||||
"related_applications": [],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#166534">
|
||||
<title>Offline — Route Commerce</title>
|
||||
<style>
|
||||
:root {
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #faf8f5;
|
||||
--color-text: #1d1d1f;
|
||||
--color-text-muted: #424245;
|
||||
--color-accent: #166534;
|
||||
--color-warning: #854d0e;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Manrope, system-ui, sans-serif;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.card {
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
background: var(--color-bg);
|
||||
border-radius: 24px;
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
}
|
||||
.icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 24px;
|
||||
background: var(--color-warning);
|
||||
color: white;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
}
|
||||
h1 {
|
||||
font-family: Fraunces, Georgia, serif;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
p {
|
||||
font-size: 18px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
button {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 16px 32px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
min-height: 56px;
|
||||
}
|
||||
button:active { opacity: 0.85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card" role="status">
|
||||
<div class="icon" aria-hidden="true">📡</div>
|
||||
<h1>You're offline</h1>
|
||||
<p>This page isn't available without a connection. Cached pages will still work. Your changes will sync when you're back online.</p>
|
||||
<button type="button" onclick="location.reload()">Try again</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -1,130 +1,108 @@
|
||||
// Service Worker for PWA - Caching and offline support
|
||||
// public/sw.js
|
||||
// Route Commerce Service Worker
|
||||
// Two-cache strategy: shell (cache-first) + data (stale-while-revalidate)
|
||||
|
||||
const CACHE_NAME = "route-commerce-v1";
|
||||
const OFFLINE_URL = "/offline";
|
||||
const SHELL_CACHE = "rc-shell-v3";
|
||||
const DATA_CACHE = "rc-data-v3";
|
||||
const OFFLINE_URL = "/offline.html";
|
||||
|
||||
const STATIC_ASSETS = [
|
||||
const SHELL_ASSETS = [
|
||||
"/",
|
||||
"/manifest.json",
|
||||
"/favicon.svg",
|
||||
"/og-default.jpg",
|
||||
"/og-default.svg",
|
||||
"/offline.html",
|
||||
"/icons/icon-192x192.png",
|
||||
"/icons/icon-512x512.png",
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - clean old caches
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
})
|
||||
Promise.all([
|
||||
caches.keys().then((cacheNames) =>
|
||||
Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== SHELL_CACHE && name !== DATA_CACHE)
|
||||
.map((name) => caches.delete(name))
|
||||
)
|
||||
),
|
||||
self.clients.claim(),
|
||||
])
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch event - network first, fallback to cache
|
||||
self.addEventListener("fetch", (event) => {
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== "GET") return;
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Skip API requests
|
||||
if (event.request.url.includes("/api/")) return;
|
||||
// Navigations → network-first, fall back to cache, fall back to offline page
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(req)
|
||||
.then((res) => {
|
||||
const clone = res.clone();
|
||||
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
|
||||
return res;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.match(req).then((cached) => cached || caches.match(OFFLINE_URL))
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip cross-origin requests
|
||||
if (!event.request.url.startsWith(self.location.origin)) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
// Clone response for caching
|
||||
const responseClone = response.clone();
|
||||
|
||||
// Cache successful responses
|
||||
if (response.status === 200) {
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseClone);
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// Return cached response or offline page
|
||||
return caches.match(event.request).then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
// Static assets → cache-first
|
||||
if (
|
||||
url.pathname.startsWith("/_next/static/") ||
|
||||
url.pathname.startsWith("/icons/") ||
|
||||
url.pathname.startsWith("/screenshots/") ||
|
||||
url.pathname.endsWith(".svg") ||
|
||||
url.pathname.endsWith(".woff2")
|
||||
) {
|
||||
event.respondWith(
|
||||
caches.match(req).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(req).then((res) => {
|
||||
const clone = res.clone();
|
||||
if (res.status === 200) {
|
||||
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
|
||||
}
|
||||
// Return offline page for navigation requests
|
||||
if (event.request.mode === "navigate") {
|
||||
return caches.match(OFFLINE_URL);
|
||||
}
|
||||
return new Response("Network error", { status: 408 });
|
||||
return res;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Push notification handling
|
||||
self.addEventListener("push", (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
const data = event.data.json();
|
||||
|
||||
const options = {
|
||||
body: data.body,
|
||||
icon: "/icons/icon-192x192.png",
|
||||
badge: "/icons/badge-72x72.png",
|
||||
vibrate: [100, 50, 100],
|
||||
data: {
|
||||
url: data.url || "/",
|
||||
},
|
||||
actions: data.actions || [],
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(data.title, options));
|
||||
});
|
||||
|
||||
// Notification click handling
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const url = event.notification.data?.url || "/";
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
||||
// Focus existing window or open new one
|
||||
for (const client of clientList) {
|
||||
if (client.url === url && "focus" in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(url);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Background sync for offline actions
|
||||
self.addEventListener("sync", (event) => {
|
||||
if (event.tag === "sync-orders") {
|
||||
event.waitUntil(syncOrders());
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
async function syncOrders() {
|
||||
// Implement order sync logic
|
||||
console.log("Syncing orders in background...");
|
||||
}
|
||||
// API GETs (read-only data) → stale-while-revalidate
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
event.respondWith(
|
||||
caches.open(DATA_CACHE).then((cache) =>
|
||||
cache.match(req).then((cached) => {
|
||||
const network = fetch(req)
|
||||
.then((res) => {
|
||||
if (res.status === 200) cache.put(req, res.clone());
|
||||
return res;
|
||||
})
|
||||
.catch(() => cached);
|
||||
return cached || network;
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: try network, fall back to cache
|
||||
event.respondWith(
|
||||
fetch(req).catch(() => caches.match(req))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* apply-admin-create-stop.js
|
||||
*
|
||||
* Applies the admin_create_stop RPC using ONLY the keys in .env.local.
|
||||
* You do NOT need Supabase dashboard / SQL editor access.
|
||||
*
|
||||
* This is the fix for the "could not find the function public.admin_create_stop(...)"
|
||||
* error when you click "Add New Stop".
|
||||
*
|
||||
* Usage (run from the repo root on a machine that can reach the DB):
|
||||
* node scripts/apply-admin-create-stop.js
|
||||
*
|
||||
* The script connects as the real postgres user (using your SUPABASE_SERVICE_ROLE_KEY
|
||||
* as the password) and runs the CREATE OR REPLACE + GRANT + NOTIFY.
|
||||
* This is the only reliable way to create the SECURITY DEFINER function because
|
||||
* direct REST inserts are blocked by the `block_stops_mutations` policy.
|
||||
*
|
||||
* After it prints success:
|
||||
* - Restart your `npm run dev`
|
||||
* - "Add New Stop" (AddStopModal / NewStopForm) will now call the RPC successfully.
|
||||
*/
|
||||
|
||||
const { Client } = require("pg");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Load .env.local (same as the rest of the project)
|
||||
try {
|
||||
require("dotenv").config({ path: path.resolve(__dirname, "../.env.local") });
|
||||
} catch (e) {
|
||||
// dotenv might not be hoisted in some setups; the keys may already be in process.env
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl || !serviceKey) {
|
||||
console.error("❌ Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local");
|
||||
console.error(" Make sure you are running from the repo root and .env.local exists with the keys.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Derive the direct Postgres URL (same logic as supabase/push-migrations.js)
|
||||
const projectRef = supabaseUrl.replace("https://", "").split(".")[0];
|
||||
const pgHost = `db.${projectRef}.supabase.co`;
|
||||
const dbUrl = `postgresql://postgres:${encodeURIComponent(serviceKey)}@${pgHost}:5432/postgres`;
|
||||
|
||||
console.log("🔧 apply-admin-create-stop RPC installer");
|
||||
console.log(" Project:", projectRef);
|
||||
console.log(" Using service role key from .env.local (full Postgres access)");
|
||||
console.log("");
|
||||
|
||||
async function main() {
|
||||
const migrationPath = path.resolve(__dirname, "../supabase/migrations/202_fix_admin_create_stop.sql");
|
||||
|
||||
if (!fs.existsSync(migrationPath)) {
|
||||
console.error("❌ Cannot find migration file:", migrationPath);
|
||||
console.error(" The file should have been created by the previous fix.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = fs.readFileSync(migrationPath, "utf8");
|
||||
|
||||
const client = new Client({
|
||||
connectionString: dbUrl,
|
||||
ssl: { rejectUnauthorized: false },
|
||||
// Some environments need a longer timeout
|
||||
connectionTimeoutMillis: 15000,
|
||||
});
|
||||
|
||||
try {
|
||||
console.log("⏳ Connecting to Postgres (this uses port 5432 outbound)...");
|
||||
await client.connect();
|
||||
console.log("✅ Connected.");
|
||||
|
||||
console.log("⏳ Executing 202_fix_admin_create_stop.sql (CREATE OR REPLACE + GRANT + NOTIFY)...");
|
||||
const result = await client.query(sql);
|
||||
|
||||
console.log("✅ Function installed / updated successfully.");
|
||||
console.log(" Rows affected in last statement:", result.rowCount ?? "(see output above if any)");
|
||||
console.log("");
|
||||
console.log("🎉 admin_create_stop is now available via the anon key (SECURITY DEFINER).");
|
||||
|
||||
// Quick verification using the anon key over HTTPS (this is what the app uses)
|
||||
try {
|
||||
const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
const probeBody = {
|
||||
p_active: false,
|
||||
p_address: null,
|
||||
p_brand_id: "00000000-0000-0000-0000-000000000000", // will FK fail, but proves the function exists
|
||||
p_city: "verify",
|
||||
p_cutoff_time: null,
|
||||
p_date: "2099-12-31",
|
||||
p_location: "verify",
|
||||
p_state: "TS",
|
||||
p_time: "10:00",
|
||||
p_zip: null,
|
||||
};
|
||||
const probe = await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/admin_create_stop`, {
|
||||
method: "POST",
|
||||
headers: { apikey: anon, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(probeBody),
|
||||
});
|
||||
const txt = await probe.text();
|
||||
if (probe.status === 404) {
|
||||
console.log("⚠️ Post-apply probe still got 404 — you may need to wait a few seconds or the NOTIFY didn't take.");
|
||||
} else if (/foreign key|violates|brand_id/i.test(txt)) {
|
||||
console.log("✅ Post-apply probe: function exists (got expected FK error on the zero-UUID brand — this is success).");
|
||||
} else {
|
||||
console.log("✅ Post-apply probe returned:", txt.slice(0, 200));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(" (verification fetch skipped:", e.message, ")");
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("Next:");
|
||||
console.log(" • Restart your dev server (`npm run dev`) if it was already running.");
|
||||
console.log(" • Go to the stops admin page and try \"Add New Stop\".");
|
||||
console.log(" • (Optional but recommended) node scripts/verify-stop-fns.js");
|
||||
} catch (err) {
|
||||
const msg = (err && err.message) ? err.message : String(err);
|
||||
console.error("❌ Failed to apply the function.");
|
||||
console.error(" Error:", msg.slice(0, 500));
|
||||
|
||||
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo|network is unreachable|ECONNREFUSED|timeout/i.test(msg)) {
|
||||
console.log("");
|
||||
console.log("💡 Your current environment cannot reach the database on port 5432.");
|
||||
console.log(" This is common inside some containers/CI sandboxes.");
|
||||
console.log("");
|
||||
console.log(" Copy and run ONE of the following on a *normal* laptop/terminal that has");
|
||||
console.log(" outbound access to Supabase Postgres (most developer machines can reach it):");
|
||||
console.log("");
|
||||
console.log(" Option A (easiest, uses exactly the same code as this script):");
|
||||
console.log(" npm run migrate:one 202");
|
||||
console.log("");
|
||||
console.log(" Option B (psql):");
|
||||
console.log(" # Build the URL using the key from your .env.local (same as this script does):");
|
||||
console.log(" # psql \"postgresql://postgres:PUT_YOUR_SERVICE_ROLE_KEY_HERE@db." + projectRef + ".supabase.co:5432/postgres\" \\");
|
||||
console.log(" # -f supabase/migrations/202_fix_admin_create_stop.sql");
|
||||
console.log("");
|
||||
console.log(" Option C (Supabase CLI):");
|
||||
console.log(" supabase db push --db-url \"postgresql://postgres:YOUR_SERVICE_ROLE_KEY@db." + projectRef + ".supabase.co:5432/postgres\" \\");
|
||||
console.log(" supabase/migrations/202_fix_admin_create_stop.sql");
|
||||
console.log("");
|
||||
console.log(" After the command succeeds you will see the NOTIFY and the function will exist.");
|
||||
console.log(" Then restart `npm run dev` and \"Add New Stop\" will stop showing the function-not-found error.");
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
} finally {
|
||||
try { await client.end(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("Unexpected crash:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const headers = {
|
||||
apikey: serviceKey,
|
||||
Authorization: `Bearer ${serviceKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
async function rpc(name, body = {}) {
|
||||
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
|
||||
method: "POST", headers, body: JSON.stringify(body),
|
||||
});
|
||||
return { status: r.status, text: (await r.text()).slice(0, 500) };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// 1. Try calling admin_create_stop with service role
|
||||
console.log("=== 1. Call admin_create_stop (service role) ===");
|
||||
console.log(await rpc("admin_create_stop", { p_brand_id: null }));
|
||||
|
||||
// 2. Try with body
|
||||
console.log("\n=== 2. Call admin_create_stop with stub body ===");
|
||||
console.log(await rpc("admin_create_stop", {
|
||||
p_brand_id: "00000000-0000-0000-0000-000000000000",
|
||||
p_city: "test", p_state: "CO", p_location: "x", p_date: "2025-01-01", p_time: "08:00"
|
||||
}));
|
||||
|
||||
// 3. Try with a name that obviously doesn't exist to see the error format
|
||||
console.log("\n=== 3. Call a non-existent function (control) ===");
|
||||
console.log(await rpc("definitely_does_not_exist_xyz", {}));
|
||||
|
||||
// 4. Use the PostgREST introspection — query information_schema via rpc
|
||||
// PostgREST exposes pg_proc through /rest/v1/ for system tables is not allowed
|
||||
// But we can use the OpenAPI spec endpoint
|
||||
console.log("\n=== 4. OpenAPI spec — looking for admin_create_stop ===");
|
||||
const openApi = await fetch(`${url}/rest/v1/`, { headers: { apikey: anonKey, Authorization: `Bearer ${anonKey}` } });
|
||||
const text = await openApi.text();
|
||||
const matches = text.match(/admin_create_stop[a-z_]*/g) || [];
|
||||
console.log("Matches in OpenAPI:", matches);
|
||||
|
||||
// 5. Get all function names from OpenAPI
|
||||
const funcMatches = [...text.matchAll(/"([a-z][a-z0-9_]*)\s*\{/g)].map(m => m[1]);
|
||||
const stopFns = funcMatches.filter(n => n.includes("stop"));
|
||||
console.log("\nStop-related functions in OpenAPI:", stopFns);
|
||||
})();
|
||||
@@ -1,52 +0,0 @@
|
||||
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
||||
const { Client } = require("pg");
|
||||
const dns = require("dns");
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const projectRef = url.replace("https://", "").split(".")[0];
|
||||
const candidates = [
|
||||
`db.${projectRef}.supabase.co`,
|
||||
`aws-0-us-east-1.pooler.supabase.com`,
|
||||
`aws-0-us-west-1.pooler.supabase.com`,
|
||||
];
|
||||
|
||||
(async () => {
|
||||
for (const host of candidates) {
|
||||
try {
|
||||
const ip = await new Promise((resolve, reject) => {
|
||||
dns.resolve4(host, (err, addrs) => err ? reject(err) : resolve(addrs));
|
||||
});
|
||||
console.log(`${host} -> ${ip.join(", ")}`);
|
||||
} catch (e) {
|
||||
console.log(`${host} -> DNS FAIL: ${e.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Try direct DB with port 6543 (pooler) using supabase format
|
||||
// Username pattern: postgres.<project-ref>
|
||||
const client = new Client({
|
||||
host: "aws-0-us-east-1.pooler.supabase.com",
|
||||
port: 6543, database: "postgres",
|
||||
user: `postgres.${projectRef}`,
|
||||
password: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
ssl: { rejectUnauthorized: false },
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const r = await client.query(`
|
||||
SELECT p.proname,
|
||||
pg_get_function_identity_arguments(p.oid) AS id_args,
|
||||
p.prosecdef AS secdef
|
||||
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
|
||||
WHERE n.nspname='public' AND p.proname IN
|
||||
('admin_create_stop','admin_create_stops_batch','delete_stop')
|
||||
ORDER BY p.proname;
|
||||
`);
|
||||
console.log("\nFUNCTIONS IN DB:");
|
||||
console.log(JSON.stringify(r.rows, null, 2));
|
||||
} catch (e) {
|
||||
console.error("POOLER ERR:", e.message);
|
||||
} finally {
|
||||
try { await client.end(); } catch {}
|
||||
}
|
||||
})();
|
||||