Compare commits
301 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb0da05037 | |||
| 16ad0955f2 | |||
| ce652ff5de | |||
| da488b2cb0 | |||
| ad4d3c9976 | |||
| 089d77aa68 | |||
| b793cd6955 | |||
| dfd6b63888 | |||
| 0599bdc331 | |||
| 93bcbc29a0 | |||
| c9b6729482 | |||
| bf68fa8431 | |||
| b07be10cf9 | |||
| c160a1d81b | |||
| 81f3f2cc83 | |||
| 5aab432e5a | |||
| 928779e06d | |||
| b061cdd289 | |||
| 71e1792ee3 | |||
| a470b6fe9d | |||
| 61d5e3c6fa | |||
| 7d3de121c6 | |||
| 9910e588b1 | |||
| 35313b16d7 | |||
| 242c195265 | |||
| 12557f947f | |||
| 178b85a9da | |||
| fc70344dab | |||
| 3b0d0c07e3 | |||
| 6de112467a | |||
| 137b5e7ffe | |||
| 060520b7be | |||
| d34fc2434a | |||
| 9ecb496a7c | |||
| 7795263944 | |||
| b29caa0f30 | |||
| c3da54af50 | |||
| 69d8f829f1 | |||
| ce62b17898 | |||
| ac88584b8b | |||
| ca1cf143d3 | |||
| b966787daa | |||
| ca79896d5f | |||
| 98fc1d3bdd | |||
| 658f6a5b8b | |||
| 015eb33291 | |||
| 602adb4588 | |||
| 4d323af51b | |||
| 06e536fb87 | |||
| aebde02654 | |||
| d0a12d33da | |||
| 6d238bd55b | |||
| fe78645609 | |||
| fdeb2ffd7f | |||
| 96e75f781d | |||
| 826f554ae1 | |||
| bb349e42f5 | |||
| f36e5da3f9 | |||
| 72ecc02c73 | |||
| ad0a0fe4ec | |||
| ce2dc8f070 | |||
| 3d5988afd0 | |||
| 38bd3fcbc7 | |||
| 9633680e23 | |||
| adce211480 | |||
| d6d6a366e3 | |||
| ed5afe4b21 | |||
| e3cdc6deb9 | |||
| 0e4c295b10 | |||
| ab8466e021 | |||
| 6547498ea4 | |||
| 13d15883b1 | |||
| 0ea11e4db6 | |||
| 17c9c006ea | |||
| 7c09487058 | |||
| a78b0ab630 | |||
| df7b65531a | |||
| cad6d64eee | |||
| 3249ff0a55 | |||
| 27b2ded94e | |||
| ab9813b4ee | |||
| e713db0002 | |||
| f7ac9399b2 | |||
| 16a6756ad1 | |||
| f6bf91951e | |||
| 8f61fed997 | |||
| e97eb33bf1 | |||
| d0bfec9d36 | |||
| e3c1295e62 | |||
| 33626620a0 | |||
| 29d9d23a26 | |||
| 8e011da521 | |||
| 0ac4beaaa8 | |||
| 4d295ef062 | |||
| 880c52227a | |||
| 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,96 @@
|
||||
# ============================================================================
|
||||
# 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.
|
||||
|
||||
# ── Smartsheet (Water Log integration) ────────────────────────────────────
|
||||
# AES-256-GCM key used to encrypt each brand's Smartsheet API token at
|
||||
# rest in `water_smartsheet_config.encrypted_api_token`. 32 random bytes,
|
||||
# base64-encoded. REQUIRED in production — refuses to start without it.
|
||||
# Generate with: openssl rand -base64 32
|
||||
SMARTSHEET_TOKEN_ENC_KEY=
|
||||
|
||||
# Bearer secret for /api/water-log/smartsheet-sync cron route. Falls
|
||||
# back to CRON_SECRET (above) if unset. Use a unique value per env to
|
||||
# avoid cross-route authorization bleed.
|
||||
SMARTSHEET_CRON_SECRET=
|
||||
|
||||
# HTTP timeout (ms) for outbound Smartsheet API calls. Default 8000.
|
||||
# SMARTSHEET_SYNC_TIMEOUT_MS=8000
|
||||
@@ -0,0 +1,212 @@
|
||||
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 }}
|
||||
SMARTSHEET_TOKEN_ENC_KEY: ${{ secrets.SMARTSHEET_TOKEN_ENC_KEY }}
|
||||
SMARTSHEET_CRON_SECRET: ${{ secrets.SMARTSHEET_CRON_SECRET }}
|
||||
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 }}
|
||||
SMARTSHEET_TOKEN_ENC_KEY: ${{ secrets.SMARTSHEET_TOKEN_ENC_KEY }}
|
||||
SMARTSHEET_CRON_SECRET: ${{ secrets.SMARTSHEET_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"
|
||||
printf 'SMARTSHEET_TOKEN_ENC_KEY=%s\n' "$SMARTSHEET_TOKEN_ENC_KEY"
|
||||
printf 'SMARTSHEET_CRON_SECRET=%s\n' "$SMARTSHEET_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 scripts/smartsheet-backfill-entry-ids.mjs tyler@route.crispygoat.com:$APP_DIR/scripts/smartsheet-backfill-entry-ids.mjs 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..."
|
||||
# IMPORTANT: --update-env so PM2 re-reads .env. Without it PM2 keeps
|
||||
# the original env vars cached at first start and silently ignores
|
||||
# newly added ones (like SMARTSHEET_*).
|
||||
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 --update-env || 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
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Smartsheet sync cron
|
||||
|
||||
# Replaces the `vercel.json` cron entry (ignored on self-hosted Gitea).
|
||||
# Drains the Smartsheet sync queue every 15 minutes via the running
|
||||
# Next.js app's POST /api/water-log/smartsheet-sync endpoint.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 15 minutes. Gitea Actions uses standard 5-field cron syntax;
|
||||
# `0,15,30,45 * * * *` is the explicit form to avoid `*/15`
|
||||
# parser quirks across older Gitea versions.
|
||||
- cron: "0,15,30,45 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
drain-sync-queue:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Hit /api/water-log/smartsheet-sync
|
||||
env:
|
||||
SMARTSHEET_CRON_SECRET: ${{ secrets.SMARTSHEET_CRON_SECRET }}
|
||||
SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$SMARTSHEET_CRON_SECRET" ]; then
|
||||
echo "SMARTSHEET_CRON_SECRET secret not set — skipping."
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$SITE_URL" ]; then
|
||||
echo "NEXT_PUBLIC_SITE_URL secret not set — skipping."
|
||||
exit 0
|
||||
fi
|
||||
echo "POST $SITE_URL/api/water-log/smartsheet-sync"
|
||||
curl -fsS --max-time 60 -X POST \
|
||||
-H "Authorization: Bearer $SMARTSHEET_CRON_SECRET" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "User-Agent: gitea-smartsheet-cron/1" \
|
||||
"${SITE_URL%/}/api/water-log/smartsheet-sync" \
|
||||
--data '{}' \
|
||||
-w "\n[http %{http_code} in %{time_total}s]\n"
|
||||
@@ -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,23 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Canonical Remote
|
||||
|
||||
There is exactly one remote — `origin` — pointing to the self-hosted Gitea repo:
|
||||
|
||||
- **URL:** `git@git.crispygoat.com:tyler/route-commerce.git`
|
||||
- **Default branch:** `main`
|
||||
- **Deploy:** push to `origin/main` triggers `.gitea/workflows/deploy.yml`
|
||||
|
||||
Do **not** add GitHub remotes. There is no `origin` on github.com and no separate "dev" repo. If you see `github.com/dzinesco/*` URLs in `.git/config`, that is stale configuration from a previous fork and should be removed (`git remote remove`).
|
||||
|
||||
## Project Overview
|
||||
|
||||
Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.
|
||||
|
||||
Tech stack: Next.js 16 (App Router) · Supabase (auth + Postgres + RLS) · Stripe · Square · Resend (email) · Tailwind CSS v4
|
||||
Tech stack: Next.js 16 (App Router) · **Postgres** (direct) · **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 should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,14 +33,12 @@ npx tsc --noEmit # TypeScript check (no emit)
|
||||
npx playwright test # Run E2E tests (Playwright)
|
||||
```
|
||||
|
||||
> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL.
|
||||
> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
|
||||
> For direct PG mode: `pg` and `dotenv` are already in devDependencies.
|
||||
> If `get_brand_settings` migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
||||
> The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies.
|
||||
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
||||
|
||||
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details.
|
||||
|
||||
No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`).
|
||||
E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config.
|
||||
|
||||
---
|
||||
|
||||
@@ -36,28 +46,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/middleware.ts`) guards `/admin/*` routes at the edge level
|
||||
|
||||
**Key files:**
|
||||
- `src/lib/auth.ts` — Neon Auth configuration (getSession, signIn, signOut, resetPassword, requestPasswordReset)
|
||||
- `src/auth.config.ts` — Edge-safe config (baseUrl, cookieSecret)
|
||||
- `src/middleware.ts` — Edge-level route protection
|
||||
- `src/app/api/auth/sign-in/route.ts` — Email/password sign-in API
|
||||
- `src/app/api/auth/forgot-password/route.ts` — Password reset request API
|
||||
- `src/app/api/auth/reset-password/route.ts` — Password reset confirmation API
|
||||
- `src/actions/auth-actions.ts` — Server actions (signOutAction)
|
||||
- `src/actions/admin/password.ts` — Admin password update (setUserPassword)
|
||||
|
||||
**Environment variables:**
|
||||
- `NEON_AUTH_BASE_URL` — from `neonctl neon-auth status`
|
||||
- `NEON_AUTH_COOKIE_SECRET` — min 32-char secret: `openssl rand -base64 32`
|
||||
- `NEXT_PUBLIC_SITE_URL` — site URL for redirects and Origin header
|
||||
|
||||
**Single source of truth for the current admin user:** `getAdminUser()` in `src/lib/admin-permissions.ts`. It reads the Neon Auth session via `getSession()` from `@/lib/auth`, then looks up `admin_users` by email. **Never import `admin-permissions.ts` into Client Components** — use server actions instead.
|
||||
|
||||
The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary.
|
||||
|
||||
#### 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 (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
|
||||
|
||||
#### Connection
|
||||
|
||||
- `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var.
|
||||
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names.
|
||||
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase.
|
||||
|
||||
#### 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 +255,19 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
|
||||
|
||||
### Communications Module ("Harvest Reach")
|
||||
|
||||
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`.
|
||||
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.)
|
||||
|
||||
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
|
||||
|
||||
**Scheduled automations** (declared in `vercel.json`):
|
||||
- `POST /api/email-automation/abandoned-cart` — every 6h, fires abandoned-cart sequence emails
|
||||
- `POST /api/email-automation/welcome-sequence` — every 6h, fires welcome onboarding sequence
|
||||
- `POST /api/cron/send-scheduled` — daily 09:00, sends scheduled campaigns
|
||||
- `POST /api/wholesale/notifications/{send,dispatch,pickup-reminder}` — wholesale lifecycle
|
||||
- `POST /api/square/process-queue` — every 2 min, drains Square sync queue
|
||||
|
||||
These endpoints are also reachable via curl for manual triggering; the email-automation routes accept `Authorization: Bearer $CRON_SECRET`.
|
||||
|
||||
### Payments
|
||||
|
||||
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
|
||||
@@ -200,7 +282,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- All DB mutations use Supabase REST API (`fetch` to `${supabaseUrl}/rest/v1/rpc/...`) from server actions, NOT the Supabase JS client (avoids SSR cookie issues)
|
||||
- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`.
|
||||
- `gen_random_uuid()` used in migrations for primary keys
|
||||
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
|
||||
- Status enums stored as TEXT — no PostgreSQL ENUM type
|
||||
@@ -215,13 +297,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) |
|
||||
| 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 +322,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.
|
||||
+125
@@ -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
|
||||
+47
-1
@@ -169,4 +169,50 @@ Controls whether to use Square sandbox or production.
|
||||
- **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.
|
||||
@@ -2,11 +2,108 @@
|
||||
|
||||
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 (PERF_TEST_AUTH auth-bypass flag, infra pool/auth tuning, audit-2026-06-26)
|
||||
|
||||
## 2026-06: `PERF_TEST_AUTH=1` enables dev_session bypass in production (USE WITH CARE)
|
||||
|
||||
The flag is honored by **both** `src/lib/admin-permissions.ts` (`getAdminUser()`)
|
||||
and `src/proxy.ts` (edge middleware). When set to `1`, the `dev_session`
|
||||
cookie grants `platform_admin` / `brand_admin` / `store_employee` access
|
||||
**without any Neon Auth session check**, even with `NODE_ENV=production`.
|
||||
|
||||
Purpose: Playwright perf benchmarks against authenticated admin pages
|
||||
without provisioning a real Neon Auth account.
|
||||
|
||||
**NEVER set `PERF_TEST_AUTH=1` in real production.** Document in the
|
||||
deploy / hosting env-var dashboard and any incident playbook. The flag
|
||||
exists so CI/perf environments can short-circuit auth; it is not a
|
||||
sustained state for production traffic.
|
||||
|
||||
The `src/lib/auth.ts` fast-path wrapper around `getSession()` also
|
||||
short-circuits when `NEON_AUTH_BASE_URL` is unset/placeholder, which is
|
||||
the CI build-time value — that path returns `null` (treated as logged-out)
|
||||
and is safe to leave in production builds.
|
||||
|
||||
## 2026-06: QA promise-audit pass (docs/qa/audit-2026-06-26/)
|
||||
|
||||
Full audit of customer-facing marketing claims vs code reality. See
|
||||
`docs/qa/audit-2026-06-26/FINAL-REPORT.md` for TL;DR + verified checks
|
||||
and `PROMISE-AUDIT.md` for the 32-promise inventory. **The high-risk
|
||||
items were fixed in commit `bb349e4`**; the 10 remaining items need
|
||||
separate decisions (recommendations in PROPOSE section).
|
||||
|
||||
## 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
|
||||
|
||||
The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture.
|
||||
|
||||
### What changes immediately
|
||||
- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
|
||||
- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`.
|
||||
- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**).
|
||||
- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth.
|
||||
- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**.
|
||||
|
||||
### What's TBD / needs follow-up
|
||||
- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet)
|
||||
- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided**
|
||||
- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md)
|
||||
- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings?
|
||||
- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition
|
||||
- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally?
|
||||
|
||||
### Migration content that's now obsolete
|
||||
- **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice.
|
||||
- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world.
|
||||
- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
|
||||
|
||||
### Historical sections below
|
||||
The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
|
||||
|
||||
---
|
||||
|
||||
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)*
|
||||
|
||||
### Login + Link (done in this session)
|
||||
- User ran `supabase login`
|
||||
@@ -35,15 +132,23 @@ Key changes:
|
||||
- Falls back to direct `pg` only if CLI path fails.
|
||||
- Header comments updated with current recommended workflow.
|
||||
|
||||
**Recommended commands now:**
|
||||
**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):**
|
||||
```bash
|
||||
# Supabase CLI path (legacy — do not use going forward)
|
||||
supabase login
|
||||
supabase link --project-ref wnzkhezyhnfzhkhiflrp
|
||||
node supabase/push-migrations.js 148 # or any prefix
|
||||
node supabase/push-migrations.js 148 # CLI path
|
||||
# or
|
||||
npm run migrate:one 148
|
||||
```
|
||||
|
||||
```bash
|
||||
# Direct pg path (this is the future — only the pg branch is kept alive in the script)
|
||||
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
|
||||
# or
|
||||
DATABASE_URL=postgres://... npm run migrate:one 148
|
||||
```
|
||||
|
||||
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
|
||||
|
||||
---
|
||||
@@ -133,19 +238,23 @@ Verification queries (post-apply) confirmed:
|
||||
|
||||
---
|
||||
|
||||
## Current State / Gotchas
|
||||
## Current State / Gotchas (2026-06-06)
|
||||
|
||||
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. Only early ones (001/002) + many timestamped migrations from other activity are tracked.
|
||||
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project.
|
||||
- When adding **new** migrations in the future, prefer the standard `supabase migration new` flow if possible, but the custom `push-migrations.js` + numeric prefix style is still the established pattern here.
|
||||
- Storage policies, RLS, SECURITY DEFINER functions, and brand-scoped data are all over the place — test carefully after big applies.
|
||||
- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only.
|
||||
- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.)
|
||||
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase.
|
||||
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`.
|
||||
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch.
|
||||
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
|
||||
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
|
||||
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Memory
|
||||
|
||||
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
|
||||
- **Read the Direction Pivot section first** — it supersedes the older Supabase-flavored instructions.
|
||||
- Update this file with new key facts, applied migrations, or new gotchas.
|
||||
- Feel free to add dated sections.
|
||||
|
||||
@@ -245,3 +354,412 @@ 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
|
||||
|
||||
|
||||
## 2026-07: Smartsheet sync added to Water Log module
|
||||
|
||||
Per-brand Smartsheet integration that pushes new `water_log_entries`
|
||||
rows to a configured sheet. Config UI lives in
|
||||
`/admin/water-log/settings` as a self-contained card.
|
||||
|
||||
**Key files added:**
|
||||
- `db/migrations/0093_water_smartsheet_sync.sql` — 3 tables:
|
||||
`water_smartsheet_config` (one row per brand; encrypted token +
|
||||
sheet ID + column mapping + frequency + enable flag + last-sync
|
||||
metadata), `water_smartsheet_sync_queue` (one row per entry
|
||||
awaiting sync; status / attempts / backoff), `water_smartsheet_sync_log`
|
||||
(append-only Recent Activity feed for the UI). RLS via the existing
|
||||
`current_brand_id()` / `is_platform_admin()` pattern.
|
||||
- `db/schema/water-log.ts` — appended the three tables + types
|
||||
(`SmartsheetFrequency`, `SmartsheetColumnKey`, `SmartsheetColumnMapping`).
|
||||
Uses a small custom `bytea` Drizzle type since `drizzle-orm/pg-core`
|
||||
doesn't export one in this version.
|
||||
- `src/lib/crypto.ts` — AES-256-GCM helpers (`encryptToken`,
|
||||
`decryptToken`, `maskToken`). Reads `SMARTSHEET_TOKEN_ENC_KEY`.
|
||||
Throws on missing/wrong-size key — no insecure default.
|
||||
- `src/lib/smartsheet.ts` — REST API wrapper (no SDK — `smartsheet`
|
||||
npm has Node 22 compat issues). Exposes `extractSheetId`,
|
||||
`getSheetMeta`, `addRow`, `findRowByColumn`, and a typed
|
||||
`SmartsheetApiError` class.
|
||||
- `src/services/smartsheet-sync.ts` — pure sync engine:
|
||||
`syncEntryToSmartsheet`, `drainSyncQueue`, `runScheduledSync`.
|
||||
Sequential per-row processing to stay well under Smartsheet's
|
||||
300 req/min cap. Exponential backoff capped at 60 min, max 5
|
||||
attempts before the row stays `failed` permanently.
|
||||
- `src/actions/water-log/smartsheet.ts` — `"use server"` actions:
|
||||
`getSmartsheetConfig`, `saveSmartsheetConfig`, `deleteSmartsheetConfig`,
|
||||
`testSmartsheetConnection`, `triggerSyncForEntry`, `listSmartsheetSyncLog`.
|
||||
Token never returned in plaintext — UI gets `maskedToken` only.
|
||||
- `src/components/admin/water-log/SmartsheetIntegrationCard.tsx` —
|
||||
client component using `useReducer`. Sections: Enable toggle,
|
||||
Sheet URL/ID + API token + Test Connection, sync frequency radio
|
||||
group, column mapping dropdowns (populated after Test), Recent
|
||||
Activity panel.
|
||||
- `src/app/api/water-log/smartsheet-sync/route.ts` — POST cron route.
|
||||
Bearer-auth via `SMARTSHEET_CRON_SECRET` (falls back to `CRON_SECRET`).
|
||||
Optional `{brandId}` body to drain one brand.
|
||||
- `vercel.json` — added `*/15 * * * *` cron entry pointing at the
|
||||
route above. Single entry covers both 15-min and hourly frequencies
|
||||
(route filters by per-brand `sync_frequency`).
|
||||
|
||||
**Hooks into existing code:**
|
||||
- `src/actions/water-log/field.ts::submitWaterEntry` — calls
|
||||
`triggerSyncForEntry(brandId, entryId)` in a fire-and-forget void
|
||||
after the entry insert. Sync failures NEVER bubble to the field
|
||||
worker.
|
||||
- `src/app/admin/water-log/settings/page.tsx` — mounted the new card
|
||||
as a new "§ 05 — Integrations" section after the existing admin-
|
||||
PIN settings. Card takes `brandId` as a prop (CLAUDE.md "Brand ID
|
||||
Threading").
|
||||
|
||||
**Env vars (`.env.example`):**
|
||||
- `SMARTSHEET_TOKEN_ENC_KEY` — REQUIRED, 32 random bytes base64.
|
||||
Generate: `openssl rand -base64 32`.
|
||||
- `SMARTSHEET_CRON_SECRET` — optional bearer for the cron route.
|
||||
- `SMARTSHEET_SYNC_TIMEOUT_MS` — optional, default 8000.
|
||||
|
||||
**Migration push (Gitea deploy + manual):**
|
||||
```bash
|
||||
npm run migrate:one 93 # pushes 0093_water_smartsheet_sync.sql
|
||||
```
|
||||
Then provision the encryption key in **Gitea repository secrets**
|
||||
(`Settings → Secrets and variables → Actions → Secrets`):
|
||||
`SMARTSHEET_TOKEN_ENC_KEY` (paste the `openssl rand -base64 32` output).
|
||||
Optionally also set `SMARTSHEET_CRON_SECRET`.
|
||||
|
||||
**Rollback story:**
|
||||
1. Remove the cron entry from `vercel.json`.
|
||||
2. SQL rollback: `DROP TABLE water_smartsheet_sync_log;`
|
||||
`DROP TABLE water_smartsheet_sync_queue;` `DROP TABLE water_smartsheet_config;`
|
||||
3. Revert the changes to `field.ts` and `settings/page.tsx`.
|
||||
4. `git revert` the merge commit.
|
||||
|
||||
**Known sharp edges:**
|
||||
- No key rotation support — changing `SMARTSHEET_TOKEN_ENC_KEY` makes
|
||||
existing tokens unreadable; admins must re-enter. Document if/when
|
||||
Phase 2 adds a `key_version` column.
|
||||
- Share-URL Smartsheet sheets often have opaque slugs (not numeric);
|
||||
if "Test Connection" 404s with a slug URL, ask the brand admin for
|
||||
the numeric sheet ID from `File → Properties` in Smartsheet.
|
||||
- `findRowByColumn` is O(n) over up to 2,500 rows; fine for typical
|
||||
water-log sheets but switches to search-API dependency if any
|
||||
brand ever exceeds ~10k entries/year.
|
||||
|
||||
## 2026-07: Gitea API runs on internal port 3013, not the public 3000
|
||||
|
||||
When using the Gitea REST API from a script or cron job that runs
|
||||
*on the Gitea host itself*, hit `http://localhost:3013`, NOT
|
||||
`http://localhost:3000`. The public port (3000) is the reverse proxy
|
||||
and does NOT route `/api/v1/*` directly. The internal Gitea port is
|
||||
set by `LOCAL_ROOT_URL` in `/home/git/custom/conf/app.ini`.
|
||||
|
||||
This came up while adding the SMARTSHEET_TOKEN_ENC_KEY secret:
|
||||
1. Initial PUT to `localhost:3000/api/v1/...` returned HTML 404
|
||||
(the 3000 service doesn't speak the Gitea API)
|
||||
2. Switched to `localhost:3013` and got HTTP 201
|
||||
|
||||
For API tokens used in cron / automation, prefer generating them
|
||||
via `gitea admin user generate-access-token` (CLI) over the UI.
|
||||
The token shows once in stdout; capture immediately and unset.
|
||||
There's no `delete-access-token` subcommand in this Gitea version
|
||||
(`admin user delete-access-token` errors with "flag not defined") —
|
||||
to revoke, DELETE directly from the SQLite `access_token` table:
|
||||
|
||||
```bash
|
||||
sudo -S -p "" -u git python3 -c "
|
||||
import sqlite3
|
||||
c = sqlite3.connect('/home/git/data/gitea.db')
|
||||
print(c.execute(\"DELETE FROM access_token WHERE name='MY-TOKEN'\").rowcount)
|
||||
c.commit()
|
||||
" <<< "$SUDOPW"
|
||||
```
|
||||
|
||||
Gitea is on **SQLite** here (`/home/git/data/gitea.db`), not the
|
||||
`HOST = 127.0.0.1:3306` listed in app.ini (that's stale config from
|
||||
when the DB was MySQL).
|
||||
|
||||
## 2026-07: Smartsheet cron now runs via Gitea Actions, not vercel.json
|
||||
|
||||
The `*/15` cron entry in `vercel.json` is silently ignored because
|
||||
the host is self-hosted Gitea, not Vercel. Replaced with
|
||||
`.gitea/workflows/smartsheet-cron.yml`, schedule `0,15,30,45 * * * *`.
|
||||
|
||||
`deploy.yml` now writes `SMARTSHEET_TOKEN_ENC_KEY` and
|
||||
`SMARTSHEET_CRON_SECRET` into the runtime `.env`. Without this,
|
||||
any future deploy would silently drop these secrets and break
|
||||
runtime sync (encrypted token storage, cron auth).
|
||||
|
||||
If the cron ever returns 401, check `SMARTSHEET_CRON_SECRET`
|
||||
matches between Gitea repo secrets and the live `.env`. The
|
||||
runtime route at `POST /api/water-log/smartsheet-sync` falls
|
||||
back to `CRON_SECRET` if `SMARTSHEET_CRON_SECRET` is unset.
|
||||
|
||||
@@ -171,7 +171,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)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# YOLO Auth Migration — Final Report
|
||||
|
||||
**Date:** 2026-06-06 → 2026-06-07
|
||||
**Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out
|
||||
all Supabase references from the auth/admin path.
|
||||
|
||||
---
|
||||
|
||||
## What was built
|
||||
|
||||
### 1. Auth.js v5 (NextAuth) integration — DONE
|
||||
|
||||
- **`src/lib/auth.ts`** — Auth.js v5 config. Google is the only provider
|
||||
(no Supabase-backed Credentials provider). Sessions are JWTs; no DB
|
||||
adapter. Exports `handlers`, `auth`, `signIn`, `signOut`.
|
||||
- **`src/lib/admin-permissions.ts`** — `getAdminUser()` resolves the
|
||||
current admin. Three branches in precedence order:
|
||||
1. `NEXT_PUBLIC_USE_MOCK_DATA=true` → platform_admin dev shim
|
||||
2. `dev_session` cookie → matching dev shim (platform/brand/store)
|
||||
3. Auth.js session → `null` (no DB lookup yet — see Follow-ups)
|
||||
- **`src/actions/auth-actions.ts`** — `signInWithGoogle()` and
|
||||
`signOutAction()`. `signInWithPassword` was removed.
|
||||
- **`src/app/login/LoginClient.tsx`** + **`page.tsx`** — Login page
|
||||
renders "Continue with Google" (when configured) and three demo
|
||||
buttons (Platform / Brand / Store Employee) via `?demo=1`. Email /
|
||||
password form and "Forgot password" are gone.
|
||||
- **`src/app/api/auth/[...nextauth]/route.ts`** — Auth.js route handler.
|
||||
|
||||
### 2. Direct Postgres connection (`src/lib/db.ts`) — DONE
|
||||
|
||||
- Single shared `pg.Pool` with lazy initialization. Throws a clear
|
||||
error if `DATABASE_URL` is not set.
|
||||
- Exports `getPool()`, a `pool` proxy alias, `query<T>()` helper, and
|
||||
`withTx()` for transactions.
|
||||
- Tuned for Vercel/serverless: 10 max conns, 30s idle, 10s connect
|
||||
timeout. All overridable via `PG_POOL_*` env vars.
|
||||
|
||||
### 3. Server actions → `pg` (no Supabase) — DONE
|
||||
|
||||
- **`src/actions/admin/users.ts`** — Full rewrite. `getAdminUsers`,
|
||||
`createAdminUser`, `updateAdminUser`, `deleteAdminUser`,
|
||||
`setMustChangePassword`, `getBrands` now hit Postgres directly.
|
||||
`sendPasswordResetEmail` returns a stub error (no auth service
|
||||
anymore — the function is preserved for call-site compatibility).
|
||||
- The `dev_session` cookie continues to be the demo-flow bypass.
|
||||
|
||||
### 4. Cleanup — DONE
|
||||
|
||||
- **`src/actions/admin/force-login.ts`** — Deleted (the
|
||||
Emergency-Force-Login page and its `/api/force-admin` route were
|
||||
removed in the previous session).
|
||||
- **`src/actions/admin/users.ts`** — Removed the `DEV_FORCE_UID`
|
||||
magic value, the `rc_auth_uid` cookie reads (×3), the
|
||||
`headerStore` lookup, the `getServiceClient` / `getAuthClient` /
|
||||
`callRpcWithAuth` helpers, and the `devListAdminUsers` auto-provision
|
||||
branch. All Supabase imports gone.
|
||||
- **`src/app/admin/me/AdminMeClient.tsx`** — Profile / email-change
|
||||
handlers neutralized (return "contact a platform admin" — the
|
||||
Supabase auth backend that backed them is gone).
|
||||
- **`src/middleware.ts`** — `dev_session` auto-login preserved
|
||||
(issues a `platform_admin` cookie on unauthed `/admin` so the rest
|
||||
of the admin shell renders). `auth()` wrapper in place.
|
||||
|
||||
### 5. Test infrastructure — DONE
|
||||
|
||||
- **Vitest 2.1.9** with `@vitejs/plugin-react`, jsdom, manual
|
||||
`@/* → src/*` alias. No `vite-tsconfig-paths` (ESM/require
|
||||
incompatibility).
|
||||
- **`tests/setup.ts`** — Mocks `next/navigation` (not in jsdom).
|
||||
- **`tests/unit/getAdminUser.test.ts`** — 12 tests covering
|
||||
dev_session, mock-data, Auth.js session, defensive error handling.
|
||||
- **`tests/unit/auth-actions.test.ts`** — 2 tests for
|
||||
`signInWithGoogle` + `signOutAction`.
|
||||
- Both test files stub `server-only` with `vi.mock("server-only", () => ({}))`.
|
||||
- **Playwright config** — `webServer` block for `npm run dev`,
|
||||
`PLAYWRIGHT_URL` override, `reuseExistingServer: !process.env.CI`.
|
||||
- **`tests/login/login-flow.spec.ts`** — Login form rendering +
|
||||
demo mode (3 roles) tests.
|
||||
- **`tests/e2e/auth.spec.ts`** — Auth.js endpoints, middleware
|
||||
redirect behavior, /admin load with dev_session, logout.
|
||||
|
||||
### 6. `import "server-only"` markers — DONE
|
||||
|
||||
- Added to `src/lib/auth.ts`, `src/lib/admin-permissions.ts`,
|
||||
`src/lib/db.ts`, `src/actions/auth-actions.ts`,
|
||||
`src/actions/admin/users.ts`.
|
||||
- ⚠️ In `"use server"` files, the directive must be first
|
||||
(`"use server";` on line 1, then `import "server-only";`). The dev
|
||||
build surfaced this; fixed in `auth-actions.ts` and `users.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Verification status
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `npx tsc --noEmit` | ✅ 0 errors |
|
||||
| `npx vitest run` | ✅ 14/14 tests pass |
|
||||
| `npm run dev` | ✅ Boots in ~440ms |
|
||||
| `GET /login` | ✅ 200 |
|
||||
| `GET /login?demo=1` | ✅ 200 |
|
||||
| `GET /api/auth/providers` | ✅ 200 (empty list — Google not configured) |
|
||||
| `GET /` | ✅ 200 |
|
||||
| `GET /admin` (unauthed) | ✅ 307 → `/admin?demo=1` + `dev_session` cookie |
|
||||
| `GET /admin?demo=1` (with cookie) | ✅ 200 (admin layout renders) |
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
```
|
||||
M src/actions/admin/users.ts # full rewrite, pg-only
|
||||
M src/actions/auth-actions.ts # signInWithPassword removed
|
||||
M src/app/admin/me/AdminMeClient.tsx # handlers stubbed
|
||||
M src/app/login/LoginClient.tsx # email/password form removed
|
||||
M src/app/login/page.tsx # passes hasGoogle prop
|
||||
M src/lib/admin-permissions.ts # Supabase REST calls removed
|
||||
M src/lib/auth.ts # Credentials provider removed
|
||||
M tests/unit/auth-actions.test.ts # signInWithPassword tests removed
|
||||
M tests/unit/getAdminUser.test.ts # reflects no-DB behavior
|
||||
```
|
||||
|
||||
Plus deletions and creations recorded in the prior session.
|
||||
|
||||
---
|
||||
|
||||
## Follow-ups (the bigger fish)
|
||||
|
||||
The user's directive was: "git rid of all supabase references, we are
|
||||
not using it for login or anything anymore." The auth/admin path is
|
||||
now Supabase-free. **33 files** still import from Supabase — they use
|
||||
it for **data fetching** (`supabase.from(...).select(...)`), not auth.
|
||||
|
||||
**Migrating those to `pg` is a follow-up.** The pattern is the same
|
||||
in every file:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
const { data } = await supabase.from("products").select("*").eq("brand_id", id);
|
||||
|
||||
// After
|
||||
import { query } from "@/lib/db";
|
||||
const { rows } = await query<ProductRow>(
|
||||
"SELECT * FROM products WHERE brand_id = $1",
|
||||
[id],
|
||||
);
|
||||
```
|
||||
|
||||
### 33 files still importing Supabase
|
||||
|
||||
```
|
||||
src/app/admin/page.tsx, products/[id]/page.tsx, products/page.tsx,
|
||||
orders/page.tsx, stops/[id]/page.tsx, stops/new/page.tsx,
|
||||
stops/page.tsx, reports/page.tsx, taxes/page.tsx,
|
||||
settings/billing/page.tsx, settings/integrations/page.tsx,
|
||||
settings/shipping/page.tsx
|
||||
src/app/tuxedo/page.tsx, about/page.tsx, contact/ContactClientPage.tsx,
|
||||
stops/[slug]/page.tsx
|
||||
src/app/indian-river-direct/page.tsx, faq/FAQClientPage.tsx,
|
||||
contact/ContactClientPage.tsx, stops/[slug]/page.tsx
|
||||
src/app/api/tuxedo/schedule-pdf/route.ts,
|
||||
src/app/api/indian-river-direct/schedule-pdf/route.ts
|
||||
src/app/reset-password/page.tsx, src/app/test/page.tsx
|
||||
src/components/admin/AdminHeader.tsx, AdminSidebar.tsx
|
||||
src/lib/supabase.ts, src/lib/supabase/server.ts # the client modules
|
||||
src/actions/wholesale-auth.ts, reset-admin.ts, audit.ts
|
||||
src/actions/ai/preferences.ts
|
||||
```
|
||||
|
||||
### Additional follow-ups
|
||||
|
||||
1. **Provision a `pg` pool in production.** `DATABASE_URL` must be
|
||||
set in the hosting dashboard (same env var as
|
||||
`supabase/push-migrations.js` uses).
|
||||
2. **Apply migration 204** if not already applied — adds
|
||||
`email`, `auth_provider`, `auth_subject` columns to `admin_users`
|
||||
and the `get_admin_user_for_session` RPC.
|
||||
3. **Wire `getAdminUser()`'s Auth.js session branch** to call
|
||||
`get_admin_user_for_session` via `pg` once a `DATABASE_URL` is
|
||||
configured. Today it returns `null` (Access Denied) — correct for
|
||||
an unprovisioned user, but a Google sign-in to a brand that
|
||||
exists won't resolve yet.
|
||||
4. **Drop the `next-auth` Credentials provider module path** in
|
||||
`auth.ts` entirely once production confirms Google is the only
|
||||
provider in use. Currently it's gated on env var presence.
|
||||
5. **Re-implement `sendPasswordResetEmail`** if/when Auth.js v5 ships
|
||||
its built-in email provider — until then, a platform admin must
|
||||
handle resets manually.
|
||||
6. **Decommission `src/lib/supabase.ts` and `src/lib/supabase/server.ts`**
|
||||
once all 33 remaining files have been ported to `pg`. They are
|
||||
the only remaining `@supabase/*` import surface.
|
||||
7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from
|
||||
`package.json`** (last step once no file imports them).
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Dev server (boots in ~440ms)
|
||||
npm test # Vitest: 14/14 pass
|
||||
npx tsc --noEmit # Typecheck: 0 errors
|
||||
npx playwright test # E2E (skips credentials tests if env unset)
|
||||
```
|
||||
Binary file not shown.
+167
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Drizzle client + brand-scoped query helper.
|
||||
*
|
||||
* The app connects to Postgres directly via the `pg` driver. Drizzle sits
|
||||
* on top, providing typed queries. The `withBrand` wrapper is the only
|
||||
* sanctioned way to run a brand-scoped query — it sets the
|
||||
* `app.current_brand_id` GUC transaction-locally, and the database's
|
||||
* RLS policies enforce brand isolation even if application code forgets
|
||||
* a `WHERE brand_id = $1`.
|
||||
*
|
||||
* Usage (read):
|
||||
* const products = await withBrand(brandId, (db) =>
|
||||
* db.select().from(productsTable).where(eq(productsTable.active, true)),
|
||||
* );
|
||||
*
|
||||
* Usage (platform admin — sees all brands):
|
||||
* const allBrands = await withPlatformAdmin((db) =>
|
||||
* db.select().from(brandsTable),
|
||||
* );
|
||||
*
|
||||
* Usage (no brand — for the rare case the query isn't brand-scoped):
|
||||
* const plans = await withDb((db) => db.select().from(plansTable));
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import * as brands from "./schema/brands";
|
||||
import * as billing from "./schema/billing";
|
||||
import * as products from "./schema/products";
|
||||
import * as stops from "./schema/stops";
|
||||
import * as customers from "./schema/customers";
|
||||
import * as orders from "./schema/orders";
|
||||
import * as brand from "./schema/brand";
|
||||
import * as wholesale from "./schema/wholesale";
|
||||
import * as waterLog from "./schema/water-log";
|
||||
import * as communications from "./schema/communications";
|
||||
import * as marketing from "./schema/marketing";
|
||||
import * as timeTracking from "./schema/time-tracking";
|
||||
import * as shipping from "./schema/shipping";
|
||||
import * as support from "./schema/support";
|
||||
import * as files from "./schema/files";
|
||||
|
||||
const schema = {
|
||||
...brands,
|
||||
...billing,
|
||||
...products,
|
||||
...stops,
|
||||
...customers,
|
||||
...orders,
|
||||
...brand,
|
||||
...wholesale,
|
||||
...waterLog,
|
||||
...communications,
|
||||
...marketing,
|
||||
...timeTracking,
|
||||
...shipping,
|
||||
...support,
|
||||
...files,
|
||||
};
|
||||
|
||||
type Schema = typeof schema;
|
||||
export type Db = NodePgDatabase<Schema>;
|
||||
|
||||
let _pool: Pool | null = null;
|
||||
|
||||
function getPool(): Pool {
|
||||
if (_pool) return _pool;
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
"DATABASE_URL is not set. Add it to .env.local (see .env.example).",
|
||||
);
|
||||
}
|
||||
_pool = new Pool({
|
||||
connectionString,
|
||||
max: parseInt(process.env.PG_POOL_MAX ?? "50", 10),
|
||||
idleTimeoutMillis: parseInt(process.env.PG_POOL_IDLE_MS ?? "30000", 10),
|
||||
connectionTimeoutMillis: parseInt(
|
||||
process.env.PG_POOL_CONN_TIMEOUT_MS ?? "5000",
|
||||
10,
|
||||
),
|
||||
allowExitOnIdle: false,
|
||||
});
|
||||
_pool.on("error", (err) => {
|
||||
console.error("[db] idle client error", err);
|
||||
});
|
||||
return _pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` with a Drizzle client. No brand context is set — the caller
|
||||
* is responsible for RLS bypass (e.g. for the `plans` and `add_ons` tables,
|
||||
* which are not brand-scoped). For brand-scoped reads, prefer
|
||||
* `withBrand` or `withPlatformAdmin`.
|
||||
*/
|
||||
export async function withDb<T>(fn: (db: Db) => Promise<T>): Promise<T> {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
const db = drizzle(client, { schema });
|
||||
return await fn(db);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` inside a transaction with the current brand id set as a
|
||||
* transaction-local GUC. RLS policies on brand-scoped tables will allow
|
||||
* reads/writes only for rows where `brand_id` matches. Pass `null` to
|
||||
* fail open (don't set the GUC) — only useful for the migrations
|
||||
* themselves, never for app code.
|
||||
*/
|
||||
export async function withBrand<T>(
|
||||
brandId: string,
|
||||
fn: (db: Db) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return runInTransaction(async (client) => {
|
||||
// set_config(setting, value, is_local) — is_local=true makes it
|
||||
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never
|
||||
// leaks across pooled connections.
|
||||
await client.query("SELECT set_config('app.current_brand_id', $1, true)", [
|
||||
brandId,
|
||||
]);
|
||||
await client.query("SELECT set_config('app.platform_admin', 'false', true)");
|
||||
const db = drizzle(client, { schema });
|
||||
return fn(db);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` as platform admin. RLS policies permit access to all brands.
|
||||
* Use sparingly — typically only in the /admin/platform routes.
|
||||
*/
|
||||
export async function withPlatformAdmin<T>(
|
||||
fn: (db: Db) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return runInTransaction(async (client) => {
|
||||
await client.query("SELECT set_config('app.current_brand_id', '', true)");
|
||||
await client.query("SELECT set_config('app.platform_admin', 'true', true)");
|
||||
const db = drizzle(client, { schema });
|
||||
return fn(db);
|
||||
});
|
||||
}
|
||||
|
||||
async function runInTransaction<T>(
|
||||
fn: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (err) {
|
||||
try {
|
||||
await client.query("ROLLBACK");
|
||||
} catch {
|
||||
// ignore secondary rollback failure
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export { schema };
|
||||
@@ -0,0 +1,12 @@
|
||||
-- QA audit stub for Neon Auth (not needed in production).
|
||||
-- Creates the minimum neon_auth schema required by FK constraints in
|
||||
-- subsequent migrations. Real provisioning happens via Neon Auth in prod.
|
||||
-- This file is namespaced with 0000_ so it sorts/applies before 0001_init.sql.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS neon_auth;
|
||||
CREATE TABLE IF NOT EXISTS neon_auth.user (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -0,0 +1,109 @@
|
||||
-- ============================================================================
|
||||
-- 0004_water_log_admin.sql
|
||||
--
|
||||
-- Adds the three water-log tables that the Drizzle schema
|
||||
-- (db/schema/water-log.ts) declares but 0001_init.sql never created.
|
||||
-- The corresponding actions live in src/actions/water-log/* and were
|
||||
-- stubbed as "Water log is not configured in the SaaS rebuild" before
|
||||
-- this migration; calling them raised "relation does not exist" and
|
||||
-- /api/water-admin-auth returned a 500 "Server error" to the field.
|
||||
--
|
||||
-- water_admin_settings — per-brand admin PIN + portal config
|
||||
-- water_admin_sessions — /water/admin sign-in sessions
|
||||
-- water_audit_log — who changed what, when
|
||||
--
|
||||
-- All three are brand-scoped with RLS, matching the 0001 pattern
|
||||
-- (`brand_id = current_brand_id() OR is_platform_admin()`).
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- water_admin_settings
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS water_admin_settings (
|
||||
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
|
||||
-- Hashed admin PIN, format: scrypt$N$r$p$salt_b64$hash_b64
|
||||
-- (see src/lib/water-log-pin.ts). Nullable so a brand can exist
|
||||
-- without an admin PIN until one is generated.
|
||||
pin_hash TEXT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
session_duration_hours INTEGER NOT NULL DEFAULT 4,
|
||||
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)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- water_admin_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,
|
||||
-- Snapshot of the PIN hash used to sign in, so a subsequent PIN
|
||||
-- rotation invalidates the session without us having to walk every row.
|
||||
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);
|
||||
|
||||
-- ============================================================================
|
||||
-- water_audit_log
|
||||
-- ============================================================================
|
||||
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);
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS
|
||||
-- ============================================================================
|
||||
ALTER TABLE water_admin_settings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE water_admin_settings FORCE ROW LEVEL SECURITY;
|
||||
|
||||
ALTER TABLE water_admin_sessions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE water_admin_sessions FORCE ROW LEVEL SECURITY;
|
||||
|
||||
ALTER TABLE water_audit_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE water_audit_log FORCE ROW LEVEL SECURITY;
|
||||
|
||||
-- ============================================================================
|
||||
-- Policies
|
||||
-- ============================================================================
|
||||
|
||||
-- water_admin_settings: PK is brand_id, so a single per-brand row.
|
||||
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());
|
||||
|
||||
-- water_admin_sessions: keyed off brand_id directly.
|
||||
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());
|
||||
|
||||
-- water_audit_log: keyed off brand_id directly.
|
||||
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,19 @@
|
||||
-- ============================================================================
|
||||
-- 0005_water_admin_pin_hash.sql
|
||||
--
|
||||
-- The Drizzle schema (db/schema/water-log.ts) declares
|
||||
-- `water_admin_settings.pin_hash TEXT` for storing the hashed admin PIN.
|
||||
-- The original table was created in 0090_water_log_completion.sql without
|
||||
-- that column, so every Drizzle `select()` / `insert()` against the table
|
||||
-- throws `column "pin_hash" does not exist`, which the water-admin auth
|
||||
-- route surfaces as 500 "Server error" and the settings page can't load
|
||||
-- (the action's promise rejects inside withBrand, leaving the page stuck
|
||||
-- on "Loading…").
|
||||
--
|
||||
-- The 0004 migration that previously created this table was a no-op
|
||||
-- against prod (the table already existed from 0090) and so didn't add
|
||||
-- the column either. This migration fixes that.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE water_admin_settings
|
||||
ADD COLUMN IF NOT EXISTS pin_hash TEXT;
|
||||
@@ -0,0 +1,196 @@
|
||||
-- Migration 0040: Platform command center — founder_pain_log table/view + RPCs
|
||||
-- Restores objects that the /admin/command-center page depends on.
|
||||
-- Original definitions were in archived Supabase migrations 126 + 127.
|
||||
|
||||
-- ── founder_pain_log table ───────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS founder_pain_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
|
||||
category TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolved_by UUID REFERENCES admin_users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- ── founder_pain_log_platform view (joins brand name/slug for the UI) ────────
|
||||
CREATE OR REPLACE VIEW founder_pain_log_platform AS
|
||||
SELECT
|
||||
fpl.id,
|
||||
fpl.brand_id,
|
||||
fpl.severity,
|
||||
fpl.category,
|
||||
fpl.title,
|
||||
fpl.description,
|
||||
fpl.status,
|
||||
fpl.created_at,
|
||||
fpl.resolved_at,
|
||||
fpl.resolved_by,
|
||||
b.name AS brand_name,
|
||||
b.slug AS brand_slug
|
||||
FROM founder_pain_log fpl
|
||||
LEFT JOIN brands b ON b.id = fpl.brand_id
|
||||
ORDER BY
|
||||
CASE fpl.severity
|
||||
WHEN 'critical' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
WHEN 'medium' THEN 3
|
||||
WHEN 'low' THEN 4
|
||||
END,
|
||||
fpl.created_at DESC;
|
||||
|
||||
-- ── Platform-wide metrics ────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION get_platform_command_center_metrics()
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
result JSONB;
|
||||
BEGIN
|
||||
result := jsonb_build_object(
|
||||
'active_brands', (
|
||||
SELECT COUNT(*) FROM brands WHERE slug IN ('tuxedo', 'indian-river-direct')
|
||||
),
|
||||
'orders_today', (
|
||||
SELECT COUNT(*) FROM orders
|
||||
WHERE DATE(created_at) = CURRENT_DATE
|
||||
),
|
||||
'revenue_today', (
|
||||
SELECT COALESCE(SUM(subtotal), 0)
|
||||
FROM orders
|
||||
WHERE DATE(created_at) = CURRENT_DATE
|
||||
AND status NOT IN ('cancelled', 'refunded')
|
||||
),
|
||||
'active_routes', (
|
||||
SELECT COUNT(DISTINCT s.id)
|
||||
FROM stops s
|
||||
WHERE s.date::DATE >= CURRENT_DATE
|
||||
AND s.status = 'active'
|
||||
AND s.date ~ '^\d{4}-\d{2}-\d{2}$'
|
||||
),
|
||||
'failed_orders_today', (
|
||||
SELECT COUNT(*)
|
||||
FROM orders
|
||||
WHERE DATE(created_at) = CURRENT_DATE
|
||||
AND status IN ('payment_failed', 'failed')
|
||||
),
|
||||
'pending_orders_today', (
|
||||
SELECT COUNT(*)
|
||||
FROM orders
|
||||
WHERE DATE(created_at) = CURRENT_DATE
|
||||
AND status = 'pending'
|
||||
)
|
||||
);
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Recent activity feed (last 50 operational events across all brands) ──────
|
||||
CREATE OR REPLACE FUNCTION get_platform_activity_feed()
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
event_type TEXT,
|
||||
entity_type TEXT,
|
||||
entity_id UUID,
|
||||
payload JSONB,
|
||||
actor_type TEXT,
|
||||
source TEXT,
|
||||
created_at TIMESTAMPTZ,
|
||||
brand_id UUID,
|
||||
brand_name TEXT
|
||||
)
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
oe.id,
|
||||
oe.event_type,
|
||||
oe.entity_type,
|
||||
oe.entity_id,
|
||||
oe.payload,
|
||||
oe.actor_type::TEXT,
|
||||
oe.source::TEXT,
|
||||
oe.created_at,
|
||||
(oe.payload->>'brand_id')::UUID AS brand_id,
|
||||
b.name AS brand_name
|
||||
FROM operational_events oe
|
||||
LEFT JOIN brands b ON b.id = (oe.payload->>'brand_id')::UUID
|
||||
ORDER BY oe.created_at DESC
|
||||
LIMIT 50;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Per-brand health snapshot ────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION get_brand_health_snapshot()
|
||||
RETURNS TABLE (
|
||||
brand_id UUID,
|
||||
brand_name TEXT,
|
||||
brand_slug TEXT,
|
||||
plan_tier TEXT,
|
||||
orders_today INT,
|
||||
revenue_today NUMERIC,
|
||||
active_stops INT,
|
||||
failed_orders INT,
|
||||
pending_orders INT,
|
||||
last_activity TIMESTAMPTZ,
|
||||
open_pain_items INT,
|
||||
health_status TEXT
|
||||
)
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH bm AS (
|
||||
SELECT
|
||||
b.id AS bid,
|
||||
b.name AS bname,
|
||||
b.slug AS bslug,
|
||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE) AS ords_today,
|
||||
SUM(o.subtotal) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status NOT IN ('cancelled', 'refunded')) AS rev_today,
|
||||
COUNT(DISTINCT s.id) FILTER (
|
||||
WHERE (s.date ~ '^\d{4}-\d{2}-\d{2}$') AND (s.date::DATE >= CURRENT_DATE) AND s.status = 'active'
|
||||
) AS act_stops,
|
||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status IN ('payment_failed', 'failed')) AS fail_ords,
|
||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status = 'pending') AS pend_ords,
|
||||
MAX(oe.created_at) AS last_act
|
||||
FROM brands b
|
||||
LEFT JOIN orders o ON o.brand_id = b.id
|
||||
LEFT JOIN stops s ON s.brand_id = b.id
|
||||
LEFT JOIN operational_events oe ON oe.payload->>'brand_id' = b.id::TEXT
|
||||
WHERE b.slug IN ('tuxedo', 'indian-river-direct')
|
||||
GROUP BY b.id, b.name, b.slug
|
||||
),
|
||||
pc AS (
|
||||
SELECT f.brand_id AS pc_bid, COUNT(*) AS open_cnt
|
||||
FROM founder_pain_log f
|
||||
WHERE f.status = 'open'
|
||||
GROUP BY f.brand_id
|
||||
)
|
||||
SELECT
|
||||
bm.bid,
|
||||
bm.bname,
|
||||
bm.bslug,
|
||||
NULL::TEXT,
|
||||
COALESCE(bm.ords_today, 0)::INT,
|
||||
COALESCE(bm.rev_today, 0)::NUMERIC,
|
||||
COALESCE(bm.act_stops, 0)::INT,
|
||||
COALESCE(bm.fail_ords, 0)::INT,
|
||||
COALESCE(bm.pend_ords, 0)::INT,
|
||||
bm.last_act,
|
||||
COALESCE(pc.open_cnt, 0)::INT,
|
||||
CASE
|
||||
WHEN COALESCE(bm.fail_ords, 0) > 0 THEN 'critical'
|
||||
WHEN COALESCE(bm.pend_ords, 0) > 5 THEN 'warning'
|
||||
WHEN COALESCE(pc.open_cnt, 0) > 0 THEN 'warning'
|
||||
WHEN bm.last_act IS NULL OR bm.last_act < (NOW() - INTERVAL '24 hours') THEN 'warning'
|
||||
ELSE 'healthy'
|
||||
END
|
||||
FROM bm
|
||||
LEFT JOIN pc ON pc.pc_bid = bm.bid;
|
||||
END;
|
||||
$$;
|
||||
@@ -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,87 @@
|
||||
-- 0092_email_templates_and_campaigns.sql
|
||||
--
|
||||
-- Creates the `email_templates` and `campaigns` tables referenced by
|
||||
-- `db/schema/marketing.ts` (Drizzle). These tables were defined in the
|
||||
-- schema but never migrated, so the "Create Template" UI on the
|
||||
-- communications page errored with `relation "email_templates" does
|
||||
-- not exist`.
|
||||
--
|
||||
-- A parallel set of tables (`communication_templates`,
|
||||
-- `communication_campaigns`) already exists from 0001_init.sql — those
|
||||
-- are a different schema used by the Harvest Reach module and are
|
||||
-- untouched here. This migration only adds the marketing-schema tables
|
||||
-- the `upsertTemplate` / `createCampaign` actions need.
|
||||
--
|
||||
-- Conventions (mirror 0001_init.sql):
|
||||
-- - gen_random_uuid() for PKs
|
||||
-- - TIMESTAMPTZ for timestamps
|
||||
-- - TEXT + CHECK for the campaigns.status enum
|
||||
-- - CREATE TABLE IF NOT EXISTS for re-runnability
|
||||
-- - set_updated_at() trigger for updated_at maintenance
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================================
|
||||
-- email_templates
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS email_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
body_html TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
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 = 'email_templates_set_updated_at' AND n.nspname = current_schema()
|
||||
) THEN
|
||||
CREATE TRIGGER email_templates_set_updated_at
|
||||
BEFORE UPDATE ON email_templates
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS email_templates_brand_idx ON email_templates (brand_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- campaigns
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS campaigns (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
template_id UUID REFERENCES email_templates(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'canceled')),
|
||||
scheduled_for TIMESTAMPTZ,
|
||||
sent_at TIMESTAMPTZ,
|
||||
recipient_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
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 = 'campaigns_set_updated_at' AND n.nspname = current_schema()
|
||||
) THEN
|
||||
CREATE TRIGGER campaigns_set_updated_at
|
||||
BEFORE UPDATE ON campaigns
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS campaigns_brand_idx ON campaigns (brand_id);
|
||||
CREATE INDEX IF NOT EXISTS campaigns_status_idx ON campaigns (brand_id, status);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,188 @@
|
||||
-- ============================================================================
|
||||
-- 0093_water_smartsheet_sync.sql
|
||||
--
|
||||
-- Adds per-brand Smartsheet integration for the Water Log module.
|
||||
--
|
||||
-- Three tables:
|
||||
-- 1. water_smartsheet_config — one row per brand; encrypted token,
|
||||
-- sheet id, column mapping, frequency,
|
||||
-- enable flag, last-sync metadata
|
||||
-- 2. water_smartsheet_sync_queue — one row per water_log_entry that
|
||||
-- needs syncing; tracks attempts + status
|
||||
-- for retry / observability
|
||||
-- 3. water_smartsheet_sync_log — append-only log of every sync attempt
|
||||
-- (success OR failure) — backs the
|
||||
-- "Recent Activity" panel in the UI
|
||||
--
|
||||
-- Security:
|
||||
-- - API token is AES-256-GCM encrypted at rest using a key from
|
||||
-- SMARTSHEET_TOKEN_ENC_KEY. The token NEVER leaves the server in
|
||||
-- plaintext via any server action / API response.
|
||||
-- - All tables brand-scoped via the existing `app.current_brand_id`
|
||||
-- GUC + `current_brand_id()` function (see 0001_init.sql).
|
||||
-- ============================================================================
|
||||
|
||||
-- ── 1. Config table ────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS water_smartsheet_config (
|
||||
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
|
||||
|
||||
-- The numeric Smartsheet sheet ID (not the share URL).
|
||||
-- UI accepts either form and parses to numeric here.
|
||||
sheet_id TEXT NOT NULL,
|
||||
|
||||
-- AES-256-GCM encrypted API token (stored as BYTEA so we can store
|
||||
-- raw bytes, not base64). Use the helpers in src/lib/crypto.ts.
|
||||
encrypted_api_token BYTEA NOT NULL,
|
||||
token_iv BYTEA NOT NULL, -- 12 random bytes per encrypt
|
||||
token_auth_tag BYTEA NOT NULL, -- 16-byte GCM auth tag
|
||||
|
||||
-- Maps our internal water-log fields → Smartsheet column IDs.
|
||||
-- Shape: { entry_id: string, logged_at: string, headgate: string,
|
||||
-- measurement: string, unit: string, irrigator: string,
|
||||
-- notes: string | null }
|
||||
-- `entry_id` and `logged_at` are required (used for dedup).
|
||||
column_mapping JSONB NOT NULL,
|
||||
|
||||
-- 'realtime' | 'every_15_minutes' | 'hourly'
|
||||
sync_frequency TEXT NOT NULL DEFAULT 'hourly'
|
||||
CHECK (sync_frequency IN ('realtime','every_15_minutes','hourly')),
|
||||
|
||||
sync_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Set after every sync attempt (success or failure).
|
||||
last_sync_at TIMESTAMPTZ,
|
||||
last_sync_error TEXT,
|
||||
|
||||
-- User IDs from `neon_auth.user`; stored as text so we don't
|
||||
-- require a FK to a specific auth backend.
|
||||
created_by TEXT,
|
||||
updated_by TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Auto-bump updated_at on row UPDATE.
|
||||
DROP TRIGGER IF EXISTS water_smartsheet_config_set_updated_at ON water_smartsheet_config;
|
||||
CREATE TRIGGER water_smartsheet_config_set_updated_at
|
||||
BEFORE UPDATE ON water_smartsheet_config
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- RLS: brand-scoped.
|
||||
ALTER TABLE water_smartsheet_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON water_smartsheet_config;
|
||||
CREATE POLICY tenant_isolation ON water_smartsheet_config FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
-- ── 2. Sync queue (one row per water_log_entry awaiting sync) ──────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS water_smartsheet_sync_queue (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
|
||||
-- FK to the entry. ON DELETE CASCADE means deleting an entry also
|
||||
-- removes its queue row, preventing orphaned sync attempts.
|
||||
entry_id UUID NOT NULL REFERENCES water_log_entries(id) ON DELETE CASCADE,
|
||||
|
||||
-- The Smartsheet row ID returned from a successful sync.
|
||||
-- NULL = not yet synced.
|
||||
smartsheet_row_id TEXT,
|
||||
|
||||
-- 'pending' | 'syncing' | 'synced' | 'failed'
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','syncing','synced','failed')),
|
||||
|
||||
-- Bumped on every attempt (success or failure). Caps at 5 (after
|
||||
-- which the row stays 'failed' permanently; admin must re-enable).
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Last failure reason (sanitized — token never appears here).
|
||||
last_error TEXT,
|
||||
|
||||
-- When the next retry is allowed. Set to NOW() initially; pushed
|
||||
-- out by exponential backoff on failure (2^attempts minutes, cap 1h).
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
synced_at TIMESTAMPTZ,
|
||||
|
||||
-- One queue row per entry per brand. CASCADE handles the case where
|
||||
-- the entry is deleted (rare; only via admin override).
|
||||
CONSTRAINT water_smartsheet_queue_entry_unique UNIQUE (brand_id, entry_id)
|
||||
);
|
||||
|
||||
-- Hot path: drain query scans `(brand_id, status, next_attempt_at)`.
|
||||
CREATE INDEX IF NOT EXISTS water_smartsheet_queue_brand_status_idx
|
||||
ON water_smartsheet_sync_queue (brand_id, status, next_attempt_at);
|
||||
|
||||
-- Recent-attempts view.
|
||||
CREATE INDEX IF NOT EXISTS water_smartsheet_queue_brand_created_idx
|
||||
ON water_smartsheet_sync_queue (brand_id, created_at DESC);
|
||||
|
||||
ALTER TABLE water_smartsheet_sync_queue ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON water_smartsheet_sync_queue;
|
||||
CREATE POLICY tenant_isolation ON water_smartsheet_sync_queue FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
-- ── 3. Sync log (append-only, backs Recent Activity UI) ────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS water_smartsheet_sync_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
|
||||
entry_id UUID REFERENCES water_log_entries(id) ON DELETE SET NULL,
|
||||
smartsheet_row_id TEXT,
|
||||
|
||||
-- 'sync' (initial push) | 'retry' (after a failure) | 'skip'
|
||||
-- (dedup hit — entry already had smartsheet_row_id) | 'test'
|
||||
-- (the "Test Connection" button does NOT log here; that's a config
|
||||
-- event recorded via water_audit_log instead).
|
||||
action TEXT NOT NULL
|
||||
CHECK (action IN ('sync','retry','skip','queue')),
|
||||
|
||||
success BOOLEAN NOT NULL,
|
||||
error TEXT,
|
||||
duration_ms INT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS water_smartsheet_log_brand_recent_idx
|
||||
ON water_smartsheet_sync_log (brand_id, created_at DESC);
|
||||
|
||||
ALTER TABLE water_smartsheet_sync_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON water_smartsheet_sync_log;
|
||||
CREATE POLICY tenant_isolation ON water_smartsheet_sync_log FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
-- ── 4. Helper view: latest sync per entry (for dedup decisions) ────────────
|
||||
|
||||
-- One row per entry with the most recent sync attempt. Used by the
|
||||
-- sync service to decide "has this entry been pushed already?".
|
||||
CREATE OR REPLACE VIEW water_smartsheet_latest_sync AS
|
||||
SELECT DISTINCT ON (q.brand_id, q.entry_id)
|
||||
q.brand_id,
|
||||
q.entry_id,
|
||||
q.smartsheet_row_id,
|
||||
q.status,
|
||||
q.attempts,
|
||||
q.last_error,
|
||||
q.next_attempt_at,
|
||||
q.created_at AS queued_at,
|
||||
q.synced_at
|
||||
FROM water_smartsheet_sync_queue q
|
||||
ORDER BY q.brand_id, q.entry_id, q.created_at DESC;
|
||||
|
||||
COMMENT ON TABLE water_smartsheet_config IS
|
||||
'Per-brand Smartsheet integration config. Token is AES-256-GCM encrypted.';
|
||||
COMMENT ON TABLE water_smartsheet_sync_queue IS
|
||||
'One row per water_log_entry awaiting / completed sync to Smartsheet.';
|
||||
COMMENT ON TABLE water_smartsheet_sync_log IS
|
||||
'Append-only audit of Smartsheet sync attempts (success and failure).';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- ============================================================================
|
||||
-- 0094_time_tracking_one_open_clock_in.sql
|
||||
--
|
||||
-- Cycle 2 of the water-log/time-tracking refactor. A field worker must not
|
||||
-- have two open clock-ins at once — concurrent requests, offline retries,
|
||||
-- and double-taps on a slow phone all create the same hazard. A partial
|
||||
-- unique index on (worker_id) WHERE clock_out IS NULL is the only DB-level
|
||||
-- guard, since READ COMMITTED transactions can both pass an
|
||||
-- "is there an open log?" SELECT before either INSERT commits.
|
||||
--
|
||||
-- The application catches the unique violation and returns
|
||||
-- "Already clocked in" to the worker. The `clockOutWorker` action picks
|
||||
-- the most-recent open log (ORDER BY clock_in DESC LIMIT 1) — that
|
||||
-- behavior is unchanged.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS
|
||||
time_tracking_logs_one_open_per_worker_idx
|
||||
ON time_tracking_logs (worker_id)
|
||||
WHERE clock_out IS NULL;
|
||||
|
||||
COMMENT ON INDEX time_tracking_logs_one_open_per_worker_idx IS
|
||||
'Cycle 2: enforces at most one open clock-in per worker. Catches concurrent / retry race in field app.';
|
||||
@@ -0,0 +1,190 @@
|
||||
-- ============================================================================
|
||||
-- 0095_time_tracking_smartsheet_sync.sql
|
||||
--
|
||||
-- Cycle 5 — Per-brand Smartsheet integration for Time Tracking, mirroring
|
||||
-- the water-log smartsheet pattern from migration 0093. Three tables:
|
||||
--
|
||||
-- 1. time_tracking_smartsheet_config — one row per brand; encrypted
|
||||
-- token, sheet id, column
|
||||
-- mapping, frequency, enable
|
||||
-- flag, last-sync metadata
|
||||
-- 2. time_tracking_smartsheet_sync_queue — one row per clock-out that
|
||||
-- needs syncing; tracks
|
||||
-- attempts + status for retry
|
||||
-- and observability
|
||||
-- 3. time_tracking_smartsheet_sync_log — append-only log of every
|
||||
-- sync attempt (success OR
|
||||
-- failure); backs the
|
||||
-- "Recent Activity" panel
|
||||
--
|
||||
-- Security:
|
||||
-- - API token is AES-256-GCM encrypted at rest using a key from
|
||||
-- SMARTSHEET_TOKEN_ENC_KEY. The token NEVER leaves the server in
|
||||
-- plaintext via any server action / API response.
|
||||
-- - All tables brand-scoped via the existing `app.current_brand_id`
|
||||
-- GUC + `current_brand_id()` function (see 0001_init.sql).
|
||||
--
|
||||
-- Cycle 5 scope:
|
||||
-- This migration ONLY introduces schema + RLS. The actual sync
|
||||
-- service, server actions, and admin UI live in their own files.
|
||||
-- The brand will fill in sheet_id + token + column mapping when
|
||||
-- the customer is ready; everything works end-to-end once they do.
|
||||
-- ============================================================================
|
||||
|
||||
-- ── 1. Config table ────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_config (
|
||||
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
|
||||
|
||||
-- The numeric Smartsheet sheet ID (not the share URL).
|
||||
-- UI accepts either form and parses to numeric here.
|
||||
sheet_id TEXT NOT NULL,
|
||||
|
||||
-- AES-256-GCM encrypted API token (stored as BYTEA so we can store
|
||||
-- raw bytes, not base64). Use the helpers in src/lib/crypto.ts.
|
||||
encrypted_api_token BYTEA NOT NULL,
|
||||
token_iv BYTEA NOT NULL, -- 12 random bytes per encrypt
|
||||
token_auth_tag BYTEA NOT NULL, -- 16-byte GCM auth tag
|
||||
|
||||
-- Maps our internal time-tracking fields → Smartsheet column IDs.
|
||||
-- Shape: { log_id: string, clock_in: string, clock_out: string,
|
||||
-- worker: string, task: string, hours: string,
|
||||
-- lunch_minutes: string | null, notes: string | null }
|
||||
-- `log_id` and `clock_in` are required (used for dedup).
|
||||
column_mapping JSONB NOT NULL,
|
||||
|
||||
-- 'realtime' | 'every_15_minutes' | 'hourly'
|
||||
sync_frequency TEXT NOT NULL DEFAULT 'hourly'
|
||||
CHECK (sync_frequency IN ('realtime','every_15_minutes','hourly')),
|
||||
|
||||
sync_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Set after every sync attempt (success or failure).
|
||||
last_sync_at TIMESTAMPTZ,
|
||||
last_sync_error TEXT,
|
||||
|
||||
-- User IDs from `neon_auth.user`; stored as text so we don't
|
||||
-- require a FK to a specific auth backend.
|
||||
created_by TEXT,
|
||||
updated_by TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Auto-bump updated_at on row UPDATE.
|
||||
DROP TRIGGER IF EXISTS time_tracking_smartsheet_config_set_updated_at ON time_tracking_smartsheet_config;
|
||||
CREATE TRIGGER time_tracking_smartsheet_config_set_updated_at
|
||||
BEFORE UPDATE ON time_tracking_smartsheet_config
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- RLS: brand-scoped.
|
||||
ALTER TABLE time_tracking_smartsheet_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_config;
|
||||
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_config FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
-- ── 2. Sync queue (one row per clock-out awaiting sync) ────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_sync_queue (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
|
||||
-- FK to the clock-out. ON DELETE CASCADE means deleting a log row
|
||||
-- also removes its queue row, preventing orphaned sync attempts.
|
||||
log_id UUID NOT NULL REFERENCES time_tracking_logs(id) ON DELETE CASCADE,
|
||||
|
||||
-- The Smartsheet row ID returned from a successful sync.
|
||||
-- NULL = not yet synced.
|
||||
smartsheet_row_id TEXT,
|
||||
|
||||
-- 'pending' | 'syncing' | 'synced' | 'failed'
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','syncing','synced','failed')),
|
||||
|
||||
-- Bumped on every attempt (success or failure). Caps at 5 (after
|
||||
-- which the row stays 'failed' permanently; admin must re-enable).
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Last failure reason (sanitized — token never appears here).
|
||||
last_error TEXT,
|
||||
|
||||
-- When the next retry is allowed. Set to NOW() initially; pushed
|
||||
-- out by exponential backoff on failure (2^attempts minutes, cap 1h).
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
synced_at TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT time_tracking_smartsheet_queue_log_unique UNIQUE (brand_id, log_id)
|
||||
);
|
||||
|
||||
-- Hot path: drain query scans `(brand_id, status, next_attempt_at)`.
|
||||
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_queue_brand_status_idx
|
||||
ON time_tracking_smartsheet_sync_queue (brand_id, status, next_attempt_at);
|
||||
|
||||
-- Recent-attempts view.
|
||||
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_queue_brand_created_idx
|
||||
ON time_tracking_smartsheet_sync_queue (brand_id, created_at DESC);
|
||||
|
||||
ALTER TABLE time_tracking_smartsheet_sync_queue ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_sync_queue;
|
||||
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_sync_queue FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
-- ── 3. Sync log (append-only, backs Recent Activity UI) ────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_sync_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
|
||||
log_id UUID REFERENCES time_tracking_logs(id) ON DELETE SET NULL,
|
||||
smartsheet_row_id TEXT,
|
||||
|
||||
-- 'sync' | 'retry' | 'skip' (dedup hit) | 'queue'
|
||||
action TEXT NOT NULL
|
||||
CHECK (action IN ('sync','retry','skip','queue')),
|
||||
|
||||
success BOOLEAN NOT NULL,
|
||||
error TEXT,
|
||||
duration_ms INT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_log_brand_recent_idx
|
||||
ON time_tracking_smartsheet_sync_log (brand_id, created_at DESC);
|
||||
|
||||
ALTER TABLE time_tracking_smartsheet_sync_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_sync_log;
|
||||
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_sync_log FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
-- ── 4. Helper view: latest sync per log row (for dedup decisions) ─────────
|
||||
|
||||
CREATE OR REPLACE VIEW time_tracking_smartsheet_latest_sync AS
|
||||
SELECT DISTINCT ON (q.brand_id, q.log_id)
|
||||
q.brand_id,
|
||||
q.log_id,
|
||||
q.smartsheet_row_id,
|
||||
q.status,
|
||||
q.attempts,
|
||||
q.last_error,
|
||||
q.next_attempt_at,
|
||||
q.created_at AS queued_at,
|
||||
q.synced_at
|
||||
FROM time_tracking_smartsheet_sync_queue q
|
||||
ORDER BY q.brand_id, q.log_id, q.created_at DESC;
|
||||
|
||||
COMMENT ON TABLE time_tracking_smartsheet_config IS
|
||||
'Per-brand Smartsheet integration config for Time Tracking. Token is AES-256-GCM encrypted.';
|
||||
COMMENT ON TABLE time_tracking_smartsheet_sync_queue IS
|
||||
'One row per time_tracking_log awaiting / completed sync to Smartsheet.';
|
||||
COMMENT ON TABLE time_tracking_smartsheet_sync_log IS
|
||||
'Append-only audit of Time Tracking ↔ Smartsheet sync attempts.';
|
||||
@@ -0,0 +1,179 @@
|
||||
-- ============================================================================
|
||||
-- 0096_smartsheet_workbook_hub.sql
|
||||
--
|
||||
-- Cycle 7 — Smartsheet workbook hub. Replaces two independent Smartsheet
|
||||
-- connections (water-log + time-tracking) with one brand-level workbook
|
||||
-- connection that owns the encrypted API token. Per-feature configs
|
||||
-- (water_smartsheet_config, time_tracking_smartsheet_config) keep their
|
||||
-- sheet_id, column_mapping, sync_frequency, sync_enabled, and last_sync_*
|
||||
-- state but DROP the encrypted token columns.
|
||||
--
|
||||
-- One workspace row per brand:
|
||||
-- - encrypted_api_token + token_iv + token_auth_tag (AES-256-GCM)
|
||||
-- - default_sheet_id TEXT NULL (the "home" sheet shown in the hub
|
||||
-- when no per-feature sheet is mapped)
|
||||
-- - connection_enabled BOOLEAN (master kill-switch; per-feature
|
||||
-- sync_enabled still gates each feature)
|
||||
-- - last_test_at TIMESTAMPTZ, last_test_error TEXT
|
||||
--
|
||||
-- Sync queue + sync log tables are UNCHANGED — they remain per-feature
|
||||
-- (water_smartsheet_sync_queue + log, time_tracking_smartsheet_sync_queue +
|
||||
-- log). The hub re-homes only the token.
|
||||
--
|
||||
-- Migration behavior (idempotent — safe to re-run):
|
||||
-- 1. Create smartsheet_workspace if missing
|
||||
-- 2. Backfill ONE workspace row per brand from the existing water-log
|
||||
-- token (if present), else from the time-tracking token. Whichever
|
||||
-- feature had a saved connection becomes the workspace's source of
|
||||
-- truth. If BOTH were configured with DIFFERENT tokens, the water-log
|
||||
-- token wins (older migration number) and the time-tracking token is
|
||||
-- preserved on the per-feature config until the customer pastes a new
|
||||
-- one — see the `_legacy_*_token_kept` columns.
|
||||
-- 3. DROP the three encrypted columns from both feature configs.
|
||||
-- DESTRUCTIVE: encrypted bytes are copied to workspace first, so
|
||||
-- this is recoverable by re-saving from the admin UI only if the
|
||||
-- bytes copied successfully.
|
||||
--
|
||||
-- Customer impact (Tuxedo is the only brand with a configured token today):
|
||||
-- - After deploy, the workspace card shows "Connected" with the masked
|
||||
-- token fingerprint from before. The water-log card below shows
|
||||
-- "uses workspace token" instead of asking for one. No re-paste.
|
||||
-- ============================================================================
|
||||
|
||||
-- ── 1. New workspace table ────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smartsheet_workspace (
|
||||
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
|
||||
|
||||
-- AES-256-GCM encrypted API token (one per brand; one per workspace).
|
||||
-- Stored as BYTEA so we can keep raw bytes (not base64).
|
||||
encrypted_api_token BYTEA NOT NULL,
|
||||
token_iv BYTEA NOT NULL, -- 12 random bytes per encrypt
|
||||
token_auth_tag BYTEA NOT NULL, -- 16-byte GCM auth tag
|
||||
|
||||
-- Optional "home" sheet — useful when the brand has a multi-sheet
|
||||
-- workbook and wants a default tab. NULL is fine.
|
||||
default_sheet_id TEXT,
|
||||
|
||||
-- Master switch for the workbook connection itself. Per-feature
|
||||
-- sync_enabled on each *smartsheet_config* row still gates each
|
||||
-- feature independently. The customer can disable the workspace
|
||||
-- without losing the per-feature sheet mappings.
|
||||
connection_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Set whenever the admin clicks "Test Connection" on the hub card.
|
||||
-- Powers the "Last verified: 2 min ago" badge in the UI.
|
||||
last_test_at TIMESTAMPTZ,
|
||||
last_test_error TEXT,
|
||||
|
||||
-- User IDs from `neon_auth.user`; stored as text so we don't
|
||||
-- require a FK to a specific auth backend.
|
||||
created_by TEXT,
|
||||
updated_by TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Auto-bump updated_at on row UPDATE.
|
||||
DROP TRIGGER IF EXISTS smartsheet_workspace_set_updated_at ON smartsheet_workspace;
|
||||
CREATE TRIGGER smartsheet_workspace_set_updated_at
|
||||
BEFORE UPDATE ON smartsheet_workspace
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- RLS: brand-scoped, same pattern as existing smartsheet tables.
|
||||
ALTER TABLE smartsheet_workspace ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON smartsheet_workspace;
|
||||
CREATE POLICY tenant_isolation ON smartsheet_workspace FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
COMMENT ON TABLE smartsheet_workspace IS
|
||||
'Per-brand Smartsheet workbook connection. Token is AES-256-GCM encrypted. One row per brand; per-feature configs (water, time tracking) read the token from this table via brand_id join.';
|
||||
|
||||
-- ── 2. Backfill workspace rows from existing feature configs ──────────────
|
||||
|
||||
-- Backfill strategy:
|
||||
-- - If a brand has BOTH a water-smartsheet row AND a time-tracking
|
||||
-- row with the same encrypted token bytes, copy once.
|
||||
-- - If only one is present, copy from that one.
|
||||
-- - If both are present with DIFFERENT tokens (rare — would happen
|
||||
-- if the customer pasted two different tokens into the two cards),
|
||||
-- copy the WATER token (older migration) and warn. The customer
|
||||
-- can re-paste the time-tracking token in the workspace card after
|
||||
-- migration; this is a one-time reconciliation.
|
||||
|
||||
INSERT INTO smartsheet_workspace (
|
||||
brand_id,
|
||||
encrypted_api_token,
|
||||
token_iv,
|
||||
token_auth_tag,
|
||||
default_sheet_id,
|
||||
connection_enabled,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
SELECT
|
||||
w.brand_id,
|
||||
w.encrypted_api_token,
|
||||
w.token_iv,
|
||||
w.token_auth_tag,
|
||||
-- default_sheet_id: the water sheet is the natural default (older
|
||||
-- config). The time-tracking sheet becomes a per-feature mapping.
|
||||
w.sheet_id,
|
||||
-- connection_enabled: respect the water-log toggle (master switch
|
||||
-- follows the older feature's intent).
|
||||
w.sync_enabled,
|
||||
w.created_by,
|
||||
w.updated_by
|
||||
FROM water_smartsheet_config w
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM smartsheet_workspace sw WHERE sw.brand_id = w.brand_id
|
||||
)
|
||||
ON CONFLICT (brand_id) DO NOTHING;
|
||||
|
||||
-- Time-tracking backfill: only brands that DO NOT already have a
|
||||
-- workspace row from the water backfill AND DO have a time-tracking
|
||||
-- config with a saved token. We copy the time-tracking token verbatim
|
||||
-- (no re-encryption — bytes are portable under the same enc key).
|
||||
INSERT INTO smartsheet_workspace (
|
||||
brand_id,
|
||||
encrypted_api_token,
|
||||
token_iv,
|
||||
token_auth_tag,
|
||||
default_sheet_id,
|
||||
connection_enabled,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
SELECT
|
||||
t.brand_id,
|
||||
t.encrypted_api_token,
|
||||
t.token_iv,
|
||||
t.token_auth_tag,
|
||||
t.sheet_id,
|
||||
t.sync_enabled,
|
||||
t.created_by,
|
||||
t.updated_by
|
||||
FROM time_tracking_smartsheet_config t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM smartsheet_workspace sw WHERE sw.brand_id = t.brand_id
|
||||
)
|
||||
ON CONFLICT (brand_id) DO NOTHING;
|
||||
|
||||
-- ── 3. Drop encrypted token columns from feature configs ────────────────
|
||||
--
|
||||
-- DESTRUCTIVE. Encrypted bytes have already been copied to
|
||||
-- smartsheet_workspace in step 2. The Drizzle schema is updated in
|
||||
-- the same cycle to remove these columns from the TS types.
|
||||
|
||||
ALTER TABLE water_smartsheet_config
|
||||
DROP COLUMN IF EXISTS encrypted_api_token,
|
||||
DROP COLUMN IF EXISTS token_iv,
|
||||
DROP COLUMN IF EXISTS token_auth_tag;
|
||||
|
||||
ALTER TABLE time_tracking_smartsheet_config
|
||||
DROP COLUMN IF EXISTS encrypted_api_token,
|
||||
DROP COLUMN IF EXISTS token_iv,
|
||||
DROP COLUMN IF EXISTS token_auth_tag;
|
||||
@@ -0,0 +1,322 @@
|
||||
-- ============================================================================
|
||||
-- 0097_field_workers.sql
|
||||
--
|
||||
-- Cycle 10 — unify `water_irrigators` and `time_tracking_workers` into a
|
||||
-- single `field_workers` table. One row per person, one `pin_hash`, one
|
||||
-- role vocabulary. Permanent fix for the silent-desync class of bugs
|
||||
-- (a worker changing their water PIN while their time PIN stays the same,
|
||||
-- or vice versa) and unlocks future domains (harvest reports, equipment
|
||||
-- logs) without yet another `*_workers` table.
|
||||
--
|
||||
-- Why one table:
|
||||
-- - Today two tables store the same person twice with separate PIN
|
||||
-- hashes and disjoint role vocabularies (`irrigator`/`water_admin` vs
|
||||
-- `worker`/`time_admin`). The Tuxedo worker PWA at `/water` already
|
||||
-- shows ONE PIN entry screen but under the hood has to look up the
|
||||
-- right row in the right table depending on which tab the worker
|
||||
-- hits (Cycle 4 attempted unification via a best-effort cross-call
|
||||
-- in `MobileWaterApp.handlePinSubmit`).
|
||||
-- - Phase 2 (separate cycle) collapses the two session cookies and
|
||||
-- replaces the dual verify actions with a single `verifyFieldWorkerPin`.
|
||||
-- This cycle ships the schema + action layer; UI behavior is preserved.
|
||||
--
|
||||
-- Schema:
|
||||
-- id UUID PK (new — old worker IDs are not reused)
|
||||
-- brand_id UUID NOT NULL → brands(id) ON DELETE CASCADE
|
||||
-- name TEXT NOT NULL
|
||||
-- pin_hash TEXT NOT NULL — scrypt self-describing format
|
||||
-- from `@/lib/water-log-pin`
|
||||
-- role TEXT NOT NULL DEFAULT 'worker'
|
||||
-- — union of all four old role values
|
||||
-- language_preference TEXT NOT NULL DEFAULT 'en'
|
||||
-- phone TEXT NULL (water-only; null for time-only workers)
|
||||
-- notes TEXT NULL (water-only)
|
||||
-- active BOOLEAN NOT NULL DEFAULT true
|
||||
-- last_used_at TIMESTAMPTZ NULL
|
||||
-- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
-- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
--
|
||||
-- Migration behavior (idempotent — safe to re-run):
|
||||
-- 1. Create field_workers + brand-scoped RLS.
|
||||
-- 2. Add nullable `field_worker_id` columns on the four downstream
|
||||
-- tables that currently FK into the old worker tables.
|
||||
-- 3. Backfill: collapse water + time rows that share a (brand_id,
|
||||
-- lower(trim(name))) key. PIN conflicts → water's wins (water is
|
||||
-- the older surface, preserves existing auth). Names unique to
|
||||
-- one side → copy verbatim.
|
||||
-- 4. Verify every downstream FK row got a field_worker_id (must be 0
|
||||
-- NULL before we make the column NOT NULL).
|
||||
-- 5. Drop the old worker tables; CASCADE drops the OLD FK columns
|
||||
-- (irrigator_id, worker_id) entirely. We do not preserve them —
|
||||
-- the new `field_worker_id` columns are populated and become the
|
||||
-- canonical reference.
|
||||
-- 6. Reissue the partial unique index
|
||||
-- `time_tracking_logs_one_open_per_worker_idx` against
|
||||
-- `field_worker_id` (DB-level invariant: at most one open
|
||||
-- clock-in per worker; preserved).
|
||||
--
|
||||
-- DESTRUCTIVE: drops `water_irrigators` and `time_tracking_workers`
|
||||
-- entirely. The pin hashes are preserved verbatim into field_workers,
|
||||
-- so no worker has to re-learn their PIN after deploy. Old IDs are not
|
||||
-- preserved — every downstream FK is re-pointed to the new IDs. This
|
||||
-- matters for any external system that referenced the old UUIDs; none
|
||||
-- of our surfaces do.
|
||||
--
|
||||
-- Customer impact (Tuxedo has 10 water + 10 time workers today, no name
|
||||
-- overlap):
|
||||
-- - After deploy: 20 `field_workers` rows, every entry/log row
|
||||
-- re-pointed at the right new UUID. Workers do NOT need to re-enter
|
||||
-- a PIN. The water PIN entry screen continues to work; the time
|
||||
-- PIN entry screen continues to work. The cross-call best-effort
|
||||
-- in `MobileWaterApp` continues to work.
|
||||
-- - The role select in `/admin/water-log/users/[id]` will be tightened
|
||||
-- to drop `time_admin` (water-only workers shouldn't get time powers).
|
||||
-- The role select in `/admin/time-tracking` will be tightened to drop
|
||||
-- the silently-rejected `supervisor`/`admin` values that the explore
|
||||
-- audit caught.
|
||||
-- ============================================================================
|
||||
|
||||
-- ── 1. Create field_workers ────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS field_workers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
pin_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK (role IN ('worker', 'time_admin', 'irrigator', 'water_admin')),
|
||||
language_preference TEXT NOT NULL DEFAULT 'en',
|
||||
phone TEXT,
|
||||
notes TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS field_workers_brand_idx
|
||||
ON field_workers(brand_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS field_workers_brand_active_idx
|
||||
ON field_workers(brand_id, active)
|
||||
WHERE active = true;
|
||||
|
||||
COMMENT ON TABLE field_workers IS
|
||||
'Cycle 10 — unified worker table replacing water_irrigators + time_tracking_workers. '
|
||||
'One row per person, one pin_hash. Role vocabulary: worker, time_admin, irrigator, water_admin. '
|
||||
'See db/migrations/0097_field_workers.sql for the unification rationale.';
|
||||
|
||||
DROP TRIGGER IF EXISTS field_workers_set_updated_at ON field_workers;
|
||||
CREATE TRIGGER field_workers_set_updated_at
|
||||
BEFORE UPDATE ON field_workers
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- Brand-scoped RLS (mirrors 0096's smartsheet_workspace pattern).
|
||||
ALTER TABLE field_workers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON field_workers;
|
||||
CREATE POLICY tenant_isolation ON field_workers FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
-- ── 2. Add nullable field_worker_id columns on the four downstream tables ─
|
||||
|
||||
ALTER TABLE water_log_entries
|
||||
ADD COLUMN IF NOT EXISTS field_worker_id UUID
|
||||
REFERENCES field_workers(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE water_sessions
|
||||
ADD COLUMN IF NOT EXISTS field_worker_id UUID
|
||||
REFERENCES field_workers(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE time_tracking_logs
|
||||
ADD COLUMN IF NOT EXISTS field_worker_id UUID
|
||||
REFERENCES field_workers(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE time_tracking_notification_log
|
||||
ADD COLUMN IF NOT EXISTS field_worker_id UUID
|
||||
REFERENCES field_workers(id) ON DELETE SET NULL;
|
||||
|
||||
-- ── 3. Backfill field_workers from the two old tables ─────────────────────
|
||||
--
|
||||
-- Insertion order is intentional: WATER first so its rows win on the
|
||||
-- (brand_id, lower(trim(name))) key (we use ON CONFLICT DO NOTHING).
|
||||
-- Time-tracking rows are inserted second; if the (brand_id, name) key
|
||||
-- already has a water row, the time row is dropped (water's PIN wins).
|
||||
|
||||
INSERT INTO field_workers (
|
||||
id, brand_id, name, pin_hash, role, language_preference,
|
||||
phone, notes, active, last_used_at, created_at
|
||||
)
|
||||
SELECT
|
||||
wi.id,
|
||||
wi.brand_id,
|
||||
wi.name,
|
||||
wi.pin_hash,
|
||||
wi.role,
|
||||
wi.language_preference,
|
||||
wi.phone,
|
||||
wi.notes,
|
||||
wi.active,
|
||||
wi.last_used_at,
|
||||
wi.created_at
|
||||
FROM water_irrigators wi
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM field_workers fw
|
||||
WHERE fw.brand_id = wi.brand_id
|
||||
AND lower(trim(fw.name)) = lower(trim(wi.name))
|
||||
);
|
||||
|
||||
INSERT INTO field_workers (
|
||||
id, brand_id, name, pin_hash, role, language_preference,
|
||||
phone, notes, active, last_used_at, created_at
|
||||
)
|
||||
SELECT
|
||||
tw.id,
|
||||
tw.brand_id,
|
||||
tw.name,
|
||||
tw.pin AS pin_hash,
|
||||
tw.role,
|
||||
tw.lang AS language_preference,
|
||||
NULL AS phone,
|
||||
NULL AS notes,
|
||||
tw.active,
|
||||
tw.last_used_at,
|
||||
tw.created_at
|
||||
FROM time_tracking_workers tw
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM field_workers fw
|
||||
WHERE fw.brand_id = tw.brand_id
|
||||
AND lower(trim(fw.name)) = lower(trim(tw.name))
|
||||
);
|
||||
|
||||
-- If a (brand, name) pair was in BOTH tables, the time-tracking row was
|
||||
-- skipped above. Re-insert only the conflicting rows using water's pin_hash
|
||||
-- is unnecessary (we already have water's row); this block is a no-op
|
||||
-- safety check.
|
||||
|
||||
-- ── 4. Backfill field_worker_id on downstream tables ──────────────────────
|
||||
--
|
||||
-- Join key: (brand_id, lower(trim(name))). The water side uses
|
||||
-- water_log_entries.irrigator_id → water_irrigators; the time side uses
|
||||
-- time_tracking_logs.worker_id → time_tracking_workers.
|
||||
|
||||
UPDATE water_log_entries wle
|
||||
SET field_worker_id = fw.id
|
||||
FROM water_irrigators wi, field_workers fw
|
||||
WHERE wle.irrigator_id = wi.id
|
||||
AND fw.brand_id = wi.brand_id
|
||||
AND lower(trim(fw.name)) = lower(trim(wi.name))
|
||||
AND wle.field_worker_id IS NULL;
|
||||
|
||||
UPDATE water_sessions ws
|
||||
SET field_worker_id = fw.id
|
||||
FROM water_irrigators wi, field_workers fw
|
||||
WHERE ws.irrigator_id = wi.id
|
||||
AND fw.brand_id = wi.brand_id
|
||||
AND lower(trim(fw.name)) = lower(trim(wi.name))
|
||||
AND ws.field_worker_id IS NULL;
|
||||
|
||||
UPDATE time_tracking_logs ttl
|
||||
SET field_worker_id = fw.id
|
||||
FROM time_tracking_workers tw, field_workers fw
|
||||
WHERE ttl.worker_id = tw.id
|
||||
AND fw.brand_id = tw.brand_id
|
||||
AND lower(trim(fw.name)) = lower(trim(tw.name))
|
||||
AND ttl.field_worker_id IS NULL;
|
||||
|
||||
UPDATE time_tracking_notification_log tnl
|
||||
SET field_worker_id = fw.id
|
||||
FROM time_tracking_workers tw, field_workers fw
|
||||
WHERE tnl.worker_id = tw.id
|
||||
AND fw.brand_id = tw.brand_id
|
||||
AND lower(trim(fw.name)) = lower(trim(tw.name))
|
||||
AND tnl.field_worker_id IS NULL;
|
||||
|
||||
-- ── 5. Verification: every downstream row must have a field_worker_id ────
|
||||
--
|
||||
-- If any of these SELECTs returns a non-zero count, the migration will
|
||||
-- leave NULL FKs. The CHECK ensures the migration FAILS LOUDLY in that
|
||||
-- case rather than silently dropping data on the cascade in step 7.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
water_entries_null INTEGER;
|
||||
water_sessions_null INTEGER;
|
||||
time_logs_null INTEGER;
|
||||
time_notif_null INTEGER;
|
||||
BEGIN
|
||||
SELECT count(*) INTO water_entries_null FROM water_log_entries WHERE field_worker_id IS NULL;
|
||||
SELECT count(*) INTO water_sessions_null FROM water_sessions WHERE field_worker_id IS NULL;
|
||||
SELECT count(*) INTO time_logs_null FROM time_tracking_logs WHERE field_worker_id IS NULL;
|
||||
SELECT count(*) INTO time_notif_null FROM time_tracking_notification_log WHERE field_worker_id IS NULL;
|
||||
|
||||
IF water_entries_null > 0 THEN
|
||||
RAISE EXCEPTION 'water_log_entries has % rows with NULL field_worker_id after backfill', water_entries_null;
|
||||
END IF;
|
||||
IF water_sessions_null > 0 THEN
|
||||
RAISE EXCEPTION 'water_sessions has % rows with NULL field_worker_id after backfill', water_sessions_null;
|
||||
END IF;
|
||||
IF time_logs_null > 0 THEN
|
||||
RAISE EXCEPTION 'time_tracking_logs has % rows with NULL field_worker_id after backfill', time_logs_null;
|
||||
END IF;
|
||||
IF time_notif_null > 0 THEN
|
||||
RAISE EXCEPTION 'time_tracking_notification_log has % rows with NULL field_worker_id after backfill', time_notif_null;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── 6. Make field_worker_id NOT NULL (we've verified backfill is complete)
|
||||
|
||||
ALTER TABLE water_log_entries
|
||||
ALTER COLUMN field_worker_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE water_sessions
|
||||
ALTER COLUMN field_worker_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE time_tracking_logs
|
||||
ALTER COLUMN field_worker_id SET NOT NULL;
|
||||
|
||||
-- time_tracking_notification_log.worker_id was originally nullable
|
||||
-- (ON DELETE SET NULL on the old FK); preserve that contract.
|
||||
|
||||
-- ── 7. Drop the old worker tables ────────────────────────────────────────
|
||||
--
|
||||
-- CASCADE removes FK constraints and dependent indexes/triggers, but does
|
||||
-- NOT drop the foreign-key columns from unrelated tables. Drop those
|
||||
-- explicitly so downstream tables carry only the new `field_worker_id`.
|
||||
|
||||
DROP TABLE IF EXISTS water_irrigators CASCADE;
|
||||
DROP TABLE IF EXISTS time_tracking_workers CASCADE;
|
||||
|
||||
ALTER TABLE water_log_entries DROP COLUMN IF EXISTS irrigator_id;
|
||||
ALTER TABLE water_sessions DROP COLUMN IF EXISTS irrigator_id;
|
||||
ALTER TABLE time_tracking_logs DROP COLUMN IF EXISTS worker_id;
|
||||
-- time_tracking_notification_log.worker_id was originally nullable
|
||||
-- (ON DELETE SET NULL); we keep the column shape and just drop the old FK
|
||||
-- constraint. The new `field_worker_id` column was added above and
|
||||
-- backfilled. We drop the old column only if the new one is populated.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM time_tracking_notification_log WHERE field_worker_id IS NULL
|
||||
) THEN
|
||||
ALTER TABLE time_tracking_notification_log DROP COLUMN worker_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── 8. Reissue the partial unique index on the new column ─────────────────
|
||||
--
|
||||
-- DB-level invariant preserved: at most one open clock-in per worker.
|
||||
|
||||
DROP INDEX IF EXISTS time_tracking_logs_one_open_per_worker_idx;
|
||||
|
||||
CREATE UNIQUE INDEX time_tracking_logs_one_open_per_worker_idx
|
||||
ON time_tracking_logs (field_worker_id)
|
||||
WHERE clock_out IS NULL;
|
||||
|
||||
-- ── 9. Summary of row counts after migration (for the audit log) ─────────
|
||||
--
|
||||
-- Run this manually after the migration completes if you want to confirm:
|
||||
-- SELECT (SELECT count(*) FROM field_workers) AS field_workers,
|
||||
-- (SELECT count(*) FROM water_log_entries WHERE field_worker_id IS NULL) AS wle_null,
|
||||
-- (SELECT count(*) FROM time_tracking_logs WHERE field_worker_id IS NULL) AS ttl_null;
|
||||
@@ -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,59 @@
|
||||
/**
|
||||
* Cycle 10 — Unified field worker table.
|
||||
*
|
||||
* Replaces `water_irrigators` + `time_tracking_workers` (one row per
|
||||
* person with two independent PIN hashes) with one row, one PIN hash,
|
||||
* one role vocabulary.
|
||||
*
|
||||
* Role vocabulary (matches the CHECK constraint in
|
||||
* `db/migrations/0097_field_workers.sql`):
|
||||
* - `worker` — default; can submit water entries + clock in/out
|
||||
* - `time_admin` — can manage time-tracking tasks, settings, and
|
||||
* other time workers
|
||||
* - `irrigator` — legacy role from `water_irrigators`; same powers
|
||||
* as `worker` but kept distinct for UI filtering
|
||||
* (the water admin UI shows this label)
|
||||
* - `water_admin` — can manage headgates, water workers, and water
|
||||
* entries
|
||||
*
|
||||
* The PIN is a scrypt self-describing hash from `@/lib/water-log-pin`
|
||||
* (column name `pin_hash`, NOT `pin` — see cycle 10 migration for the
|
||||
* rename).
|
||||
*/
|
||||
import { boolean, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
|
||||
export const fieldWorkers = pgTable(
|
||||
"field_workers",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
pinHash: text("pin_hash").notNull(),
|
||||
role: text("role", {
|
||||
enum: ["worker", "time_admin", "irrigator", "water_admin"],
|
||||
})
|
||||
.notNull()
|
||||
.default("worker"),
|
||||
languagePreference: text("language_preference").notNull().default("en"),
|
||||
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(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("field_workers_brand_idx").on(t.brandId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type FieldWorker = typeof fieldWorkers.$inferSelect;
|
||||
export type NewFieldWorker = typeof fieldWorkers.$inferInsert;
|
||||
export type FieldWorkerRole = FieldWorker["role"];
|
||||
@@ -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,20 @@
|
||||
/**
|
||||
* 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 "./field-workers";
|
||||
export * from "./shipping";
|
||||
export * from "./support";
|
||||
export * from "./files";
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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 { brands } from "./brands";
|
||||
|
||||
const campaignStatusEnum = [
|
||||
"draft",
|
||||
"scheduled",
|
||||
"sending",
|
||||
"sent",
|
||||
"canceled",
|
||||
] as const;
|
||||
|
||||
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,66 @@
|
||||
/**
|
||||
* Smartsheet workspace — per-brand workbook connection.
|
||||
*
|
||||
* Cycle 7 schema for migration 0096. Replaces two independent Smartsheet
|
||||
* configs (water + time tracking) with one brand-level workbook hub.
|
||||
*
|
||||
* The encrypted API token lives here. Per-feature configs
|
||||
* (`water_smartsheet_config`, `time_tracking_smartsheet_config`) keep
|
||||
* their sheet_id, column_mapping, sync_frequency, sync_enabled, and
|
||||
* last_sync_* state but read the token from this table via brand_id.
|
||||
*
|
||||
* RLS: brand-scoped, same pattern as existing smartsheet tables.
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
boolean,
|
||||
timestamp,
|
||||
customType,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
|
||||
const bytea = customType<{ data: Buffer; default: false }>({
|
||||
dataType() {
|
||||
return "bytea";
|
||||
},
|
||||
});
|
||||
|
||||
export const smartsheetWorkspace = pgTable("smartsheet_workspace", {
|
||||
brandId: uuid("brand_id")
|
||||
.primaryKey()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
|
||||
// AES-256-GCM encrypted API token. Same key as the old feature
|
||||
// configs (SMARTSHEET_TOKEN_ENC_KEY); bytes are portable.
|
||||
encryptedApiToken: bytea("encrypted_api_token").notNull(),
|
||||
tokenIv: bytea("token_iv").notNull(),
|
||||
tokenAuthTag: bytea("token_auth_tag").notNull(),
|
||||
|
||||
// Optional "home" sheet — the tab the brand sees first in the
|
||||
// workbook. NULL is fine; the UI falls back to the first per-
|
||||
// feature sheet mapping.
|
||||
defaultSheetId: text("default_sheet_id"),
|
||||
|
||||
// Master kill-switch. Per-feature sync_enabled still gates each
|
||||
// feature independently — toggling this off pauses all syncs.
|
||||
connectionEnabled: boolean("connection_enabled").notNull().default(true),
|
||||
|
||||
// Test-connection bookkeeping (powers the "Last verified 2m ago"
|
||||
// badge on the hub card).
|
||||
lastTestAt: timestamp("last_test_at", { withTimezone: true }),
|
||||
lastTestError: text("last_test_error"),
|
||||
|
||||
createdBy: text("created_by"),
|
||||
updatedBy: text("updated_by"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
export type SmartsheetWorkspace = typeof smartsheetWorkspace.$inferSelect;
|
||||
export type SmartsheetWorkspaceInsert = typeof smartsheetWorkspace.$inferInsert;
|
||||
@@ -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,306 @@
|
||||
/**
|
||||
* Time Tracking. Source: `db/migrations/0001_init.sql` and the
|
||||
* Cycle-5 Smartsheet scaffold (`db/migrations/0095_*.sql`).
|
||||
*
|
||||
* Cycle 7: the encrypted token columns on
|
||||
* `time_tracking_smartsheet_config` moved to `smartsheet_workspace`
|
||||
* (see `db/schema/smartsheet-workspace.ts`).
|
||||
*
|
||||
* Cycle 10: the `time_tracking_workers` table was DROPPED in
|
||||
* `db/migrations/0097_field_workers.sql`. Worker records now live
|
||||
* in `field_workers` (see `db/schema/field-workers.ts`). The
|
||||
* `worker_id` column on the downstream tables
|
||||
* (`time_tracking_logs`, `time_tracking_notification_log`) was
|
||||
* renamed to `field_worker_id` and the FK repointed.
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
numeric,
|
||||
integer,
|
||||
boolean,
|
||||
timestamp,
|
||||
index,
|
||||
jsonb,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
import { fieldWorkers } from "./field-workers";
|
||||
|
||||
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(),
|
||||
},
|
||||
);
|
||||
|
||||
// Cycle 10: `time_tracking_workers` table was DROPPED in
|
||||
// 0097_field_workers.sql. Worker records now live in `field_workers`
|
||||
// (see `db/schema/field-workers.ts`). Any code that previously read
|
||||
// from timeTrackingWorkers should now read from fieldWorkers and
|
||||
// filter by role = 'worker' or 'time_admin' if it needs time-only
|
||||
// workers.
|
||||
|
||||
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" }),
|
||||
/** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */
|
||||
fieldWorkerId: uuid("field_worker_id")
|
||||
.notNull()
|
||||
.references(() => fieldWorkers.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),
|
||||
fieldWorkerIdx: index("time_tracking_logs_field_worker_idx").on(
|
||||
t.fieldWorkerId,
|
||||
),
|
||||
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" }),
|
||||
/** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */
|
||||
fieldWorkerId: uuid("field_worker_id").references(
|
||||
() => fieldWorkers.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;
|
||||
// Cycle 10: `TimeTrackingWorker` was removed. Use `FieldWorker` from
|
||||
// `./field-workers` instead; filter by role = 'worker' or 'time_admin'
|
||||
// if you need time-only workers.
|
||||
export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
|
||||
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
|
||||
export type TimeTrackingNotificationLog =
|
||||
typeof timeTrackingNotificationLog.$inferSelect;
|
||||
|
||||
// ── Smartsheet sync (Cycle 5) ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Allowed sync frequencies. Mirrors the water-log smartsheet
|
||||
* pattern (see `db/schema/water-log.ts` SMARTSHEET_FREQUENCIES).
|
||||
*/
|
||||
export const TT_SMARTSHEET_FREQUENCIES = [
|
||||
"realtime",
|
||||
"every_15_minutes",
|
||||
"hourly",
|
||||
] as const;
|
||||
export type TTSmartsheetFrequency = (typeof TT_SMARTSHEET_FREQUENCIES)[number];
|
||||
|
||||
/**
|
||||
* Time-tracking fields a brand can map to their Smartsheet columns.
|
||||
* `log_id` and `clock_in` are required (used for dedup).
|
||||
*/
|
||||
export type TTSmartsheetColumnKey =
|
||||
| "log_id"
|
||||
| "clock_in"
|
||||
| "clock_out"
|
||||
| "worker"
|
||||
| "task"
|
||||
| "hours"
|
||||
| "lunch_minutes"
|
||||
| "notes";
|
||||
export const TT_SMARTSHEET_COLUMN_KEYS: readonly TTSmartsheetColumnKey[] = [
|
||||
"log_id",
|
||||
"clock_in",
|
||||
"clock_out",
|
||||
"worker",
|
||||
"task",
|
||||
"hours",
|
||||
"lunch_minutes",
|
||||
"notes",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Shape stored in `time_tracking_smartsheet_config.column_mapping`.
|
||||
* Required: log_id + clock_in (for dedup). All others nullable.
|
||||
*/
|
||||
export type TTSmartsheetColumnMapping = {
|
||||
log_id: string;
|
||||
clock_in: string;
|
||||
clock_out: string | null;
|
||||
worker: string | null;
|
||||
task: string | null;
|
||||
hours: string | null;
|
||||
lunch_minutes: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export const timeTrackingSmartsheetConfig = pgTable(
|
||||
"time_tracking_smartsheet_config",
|
||||
{
|
||||
brandId: uuid("brand_id")
|
||||
.primaryKey()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
sheetId: text("sheet_id").notNull(),
|
||||
// Cycle 7: encrypted token columns moved to smartsheet_workspace.
|
||||
// See migration 0096_smartsheet_workbook_hub.sql.
|
||||
columnMapping: jsonb("column_mapping")
|
||||
.$type<TTSmartsheetColumnMapping>()
|
||||
.notNull(),
|
||||
syncFrequency: text("sync_frequency")
|
||||
.$type<TTSmartsheetFrequency>()
|
||||
.notNull()
|
||||
.default("hourly"),
|
||||
syncEnabled: boolean("sync_enabled").notNull().default(false),
|
||||
lastSyncAt: timestamp("last_sync_at", { withTimezone: true }),
|
||||
lastSyncError: text("last_sync_error"),
|
||||
createdBy: text("created_by"),
|
||||
updatedBy: text("updated_by"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
);
|
||||
|
||||
export const timeTrackingSmartsheetSyncQueue = pgTable(
|
||||
"time_tracking_smartsheet_sync_queue",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
logId: uuid("log_id")
|
||||
.notNull()
|
||||
.references(() => timeTrackingLogs.id, { onDelete: "cascade" }),
|
||||
smartsheetRowId: text("smartsheet_row_id"),
|
||||
status: text("status", {
|
||||
enum: ["pending", "syncing", "synced", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
lastError: text("last_error"),
|
||||
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
syncedAt: timestamp("synced_at", { withTimezone: true }),
|
||||
},
|
||||
);
|
||||
|
||||
export const timeTrackingSmartsheetSyncLog = pgTable(
|
||||
"time_tracking_smartsheet_sync_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
logId: uuid("log_id").references(() => timeTrackingLogs.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
smartsheetRowId: text("smartsheet_row_id"),
|
||||
action: text("action", {
|
||||
enum: ["sync", "retry", "skip", "queue"],
|
||||
}).notNull(),
|
||||
success: boolean("success").notNull(),
|
||||
error: text("error"),
|
||||
durationMs: integer("duration_ms"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
);
|
||||
|
||||
export type TimeTrackingSmartsheetConfig =
|
||||
typeof timeTrackingSmartsheetConfig.$inferSelect;
|
||||
export type TimeTrackingSmartsheetConfigInsert =
|
||||
typeof timeTrackingSmartsheetConfig.$inferInsert;
|
||||
export type TimeTrackingSmartsheetSyncQueue =
|
||||
typeof timeTrackingSmartsheetSyncQueue.$inferSelect;
|
||||
export type TimeTrackingSmartsheetSyncLog =
|
||||
typeof timeTrackingSmartsheetSyncLog.$inferSelect;
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* 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
|
||||
* - field_workers — unified worker table (cycle 10; replaces
|
||||
* water_irrigators + time_tracking_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,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
import { adminUsers } from "./brands";
|
||||
import { fieldWorkers } from "./field-workers";
|
||||
|
||||
// Cycle 7: the `bytea` customType used to live here for the
|
||||
// encrypted Smartsheet token columns. Those columns moved to
|
||||
// `smartsheet_workspace` (see `db/schema/smartsheet-workspace.ts`),
|
||||
// so the helper is no longer needed here.
|
||||
|
||||
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),
|
||||
}),
|
||||
);
|
||||
|
||||
// Cycle 10: water_irrigators table was DROPPED in
|
||||
// 0097_field_workers.sql. Worker records now live in
|
||||
// `field_workers` (see `db/schema/field-workers.ts`).
|
||||
// Any code that previously read from waterIrrigators should now
|
||||
// read from fieldWorkers and filter by role = 'irrigator' or
|
||||
// 'water_admin' if it needs water-only workers.
|
||||
|
||||
export const waterSessions = pgTable(
|
||||
"water_sessions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** Cycle 10: was `irrigator_id` (FK → water_irrigators); now unified. */
|
||||
fieldWorkerId: uuid("field_worker_id")
|
||||
.notNull()
|
||||
.references(() => fieldWorkers.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" }),
|
||||
/** Cycle 10: was `irrigator_id` (FK → water_irrigators); now unified. */
|
||||
fieldWorkerId: uuid("field_worker_id")
|
||||
.notNull()
|
||||
.references(() => fieldWorkers.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,
|
||||
),
|
||||
fieldWorkerIdx: index("water_log_entries_field_worker_idx").on(
|
||||
t.fieldWorkerId,
|
||||
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;
|
||||
|
||||
// Cycle 10: `WaterIrrigator` / `WaterIrrigatorInsert` were removed.
|
||||
// Use `FieldWorker` from `./field-workers` instead; filter by role =
|
||||
// 'irrigator' or 'water_admin' if you need water-only workers.
|
||||
|
||||
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;
|
||||
|
||||
// ── Smartsheet integration (added in 0093_water_smartsheet_sync.sql) ────────
|
||||
|
||||
/**
|
||||
* Allowed sync frequencies. Stored as TEXT to match the convention
|
||||
* in CLAUDE.md ("Status enums stored as TEXT — no PostgreSQL ENUM type").
|
||||
*/
|
||||
export const SMARTSHEET_FREQUENCIES = [
|
||||
"realtime",
|
||||
"every_15_minutes",
|
||||
"hourly",
|
||||
] as const;
|
||||
export type SmartsheetFrequency = (typeof SMARTSHEET_FREQUENCIES)[number];
|
||||
|
||||
/**
|
||||
* The Water-log fields a brand can map to their Smartsheet columns.
|
||||
* `entry_id` and `logged_at` are required (used for dedup).
|
||||
*/
|
||||
export type SmartsheetColumnKey =
|
||||
| "entry_id"
|
||||
| "logged_at"
|
||||
| "headgate"
|
||||
| "measurement"
|
||||
| "unit"
|
||||
| "irrigator"
|
||||
| "notes";
|
||||
export const SMARTSHEET_COLUMN_KEYS: readonly SmartsheetColumnKey[] = [
|
||||
"entry_id",
|
||||
"logged_at",
|
||||
"headgate",
|
||||
"measurement",
|
||||
"unit",
|
||||
"irrigator",
|
||||
"notes",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Shape stored in `water_smartsheet_config.column_mapping`. The values
|
||||
* are Smartsheet column IDs (numeric strings, e.g. "8309876543210123").
|
||||
*
|
||||
* Only `entry_id` and `logged_at` are required (used for dedup). All
|
||||
* other fields are nullable — `null` means "not mapped, don't write
|
||||
* this column". The UI uses an empty string in the dropdown to mean
|
||||
* the same thing and we coerce on the server.
|
||||
*/
|
||||
export type SmartsheetColumnMapping = {
|
||||
entry_id: string;
|
||||
logged_at: string;
|
||||
headgate: string | null;
|
||||
measurement: string | null;
|
||||
unit: string | null;
|
||||
irrigator: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export const waterSmartsheetConfig = pgTable("water_smartsheet_config", {
|
||||
brandId: uuid("brand_id")
|
||||
.primaryKey()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
sheetId: text("sheet_id").notNull(),
|
||||
// Cycle 7: encrypted token columns moved to smartsheet_workspace.
|
||||
// See migration 0096_smartsheet_workbook_hub.sql.
|
||||
columnMapping: jsonb("column_mapping")
|
||||
.$type<SmartsheetColumnMapping>()
|
||||
.notNull(),
|
||||
syncFrequency: text("sync_frequency")
|
||||
.$type<SmartsheetFrequency>()
|
||||
.notNull()
|
||||
.default("hourly"),
|
||||
syncEnabled: boolean("sync_enabled").notNull().default(false),
|
||||
lastSyncAt: timestamp("last_sync_at", { withTimezone: true }),
|
||||
lastSyncError: text("last_sync_error"),
|
||||
createdBy: text("created_by"),
|
||||
updatedBy: text("updated_by"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
export type WaterSmartsheetConfig = typeof waterSmartsheetConfig.$inferSelect;
|
||||
export type WaterSmartsheetConfigInsert = typeof waterSmartsheetConfig.$inferInsert;
|
||||
|
||||
/**
|
||||
* Sync queue status. Mirrors the SQL CHECK constraint.
|
||||
*/
|
||||
export const SMARTSHEET_QUEUE_STATUSES = [
|
||||
"pending",
|
||||
"syncing",
|
||||
"synced",
|
||||
"failed",
|
||||
] as const;
|
||||
export type SmartsheetQueueStatus = (typeof SMARTSHEET_QUEUE_STATUSES)[number];
|
||||
|
||||
export const waterSmartsheetSyncQueue = pgTable(
|
||||
"water_smartsheet_sync_queue",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
entryId: uuid("entry_id")
|
||||
.notNull()
|
||||
.references(() => waterLogEntries.id, { onDelete: "cascade" }),
|
||||
smartsheetRowId: text("smartsheet_row_id"),
|
||||
status: text("status")
|
||||
.$type<SmartsheetQueueStatus>()
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
lastError: text("last_error"),
|
||||
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
syncedAt: timestamp("synced_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => ({
|
||||
entryUnique: uniqueIndex("water_smartsheet_queue_entry_unique").on(
|
||||
t.brandId,
|
||||
t.entryId,
|
||||
),
|
||||
brandStatusIdx: index("water_smartsheet_queue_brand_status_idx").on(
|
||||
t.brandId,
|
||||
t.status,
|
||||
t.nextAttemptAt,
|
||||
),
|
||||
brandRecentIdx: index("water_smartsheet_queue_brand_created_idx").on(
|
||||
t.brandId,
|
||||
t.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export type WaterSmartsheetSyncQueue = typeof waterSmartsheetSyncQueue.$inferSelect;
|
||||
export type WaterSmartsheetSyncQueueInsert = typeof waterSmartsheetSyncQueue.$inferInsert;
|
||||
|
||||
export const SMARTSHEET_LOG_ACTIONS = ["sync", "retry", "skip", "queue"] as const;
|
||||
export type SmartsheetLogAction = (typeof SMARTSHEET_LOG_ACTIONS)[number];
|
||||
|
||||
export const waterSmartsheetSyncLog = pgTable(
|
||||
"water_smartsheet_sync_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
entryId: uuid("entry_id").references(() => waterLogEntries.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
smartsheetRowId: text("smartsheet_row_id"),
|
||||
action: text("action").$type<SmartsheetLogAction>().notNull(),
|
||||
success: boolean("success").notNull(),
|
||||
error: text("error"),
|
||||
durationMs: integer("duration_ms"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandRecentIdx: index("water_smartsheet_log_brand_recent_idx").on(
|
||||
t.brandId,
|
||||
t.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export type WaterSmartsheetSyncLog = typeof waterSmartsheetSyncLog.$inferSelect;
|
||||
export type WaterSmartsheetSyncLogInsert = typeof waterSmartsheetSyncLog.$inferInsert;
|
||||
@@ -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;
|
||||
+177
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
-- QA audit production-scale seed.
|
||||
-- Idempotent (safe to re-run). Run via: docker exec -i routeqa-pg psql -U routeqa -d route_comm < db/seeds/2026-qa-audit-scale.sql
|
||||
--
|
||||
-- Per brand (tuxedo, indian-river-direct):
|
||||
-- 500 products, 50 stops, 500 customers, 1000 orders, ~2500 order_items
|
||||
-- 5 communication templates, 25 contacts, 8 campaigns, 200 message_logs
|
||||
-- 50 wholesale_customers, 200 wholesale_orders
|
||||
-- 200 time_tracking_logs, 100 water_log_entries, 50 audit_logs
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Brands
|
||||
-- ============================================================================
|
||||
INSERT INTO brands (id, name, slug, plan_tier, max_users, max_stops_monthly, max_products)
|
||||
VALUES
|
||||
('11111111-1111-1111-1111-111111111111', 'Tuxedo Citrus', 'tuxedo', 'farm', 5, 999, 999),
|
||||
('22222222-2222-2222-2222-222222222222', 'Indian River Direct', 'indian-river-direct', 'enterprise', 50, 9999, 9999)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Brand settings
|
||||
-- ============================================================================
|
||||
INSERT INTO brand_settings (brand_id, legal_business_name, phone, email, website_url,
|
||||
street_address, city, state, postal_code, country,
|
||||
tagline, about_html, primary_color, from_email, from_name, custom_footer_text)
|
||||
VALUES
|
||||
('11111111-1111-1111-1111-111111111111',
|
||||
'Tuxedo Citrus Co.', '(555) 010-2200', 'hello@tuxedocitrus.example',
|
||||
'https://tuxedocitrus.example', '123 Grove Lane', 'Vero Beach', 'FL', '32960', 'US',
|
||||
'Sun-ripened citrus, delivered.',
|
||||
'<p>Family-run citrus grove in the Indian River region.</p>',
|
||||
'#F59E0B', 'orders@tuxedocitrus.example', 'Tuxedo Citrus', '© Tuxedo Citrus Co.'),
|
||||
('22222222-2222-2222-2222-222222222222',
|
||||
'Indian River Direct LLC', '(555) 010-3300', 'orders@indianriverdirect.example',
|
||||
'https://indianriverdirect.example', '456 Citrus Way', 'Fort Pierce', 'FL', '34950', 'US',
|
||||
'From our groves to your store.',
|
||||
'<p>Direct-from-grove wholesale produce.</p>',
|
||||
'#0F766E', 'orders@indianriverdirect.example', 'Indian River Direct', '© Indian River Direct')
|
||||
ON CONFLICT (brand_id) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. Wholesale settings (per actual schema)
|
||||
-- ============================================================================
|
||||
INSERT INTO wholesale_settings (brand_id, require_approval, min_order_amount, online_payment_enabled, pickup_location, fob_location, from_email, invoice_business_name)
|
||||
VALUES
|
||||
('11111111-1111-1111-1111-111111111111', true, 500.00, true, '123 Grove Lane, Vero Beach FL', 'Origin Vero Beach FL', 'orders@tuxedocitrus.example', 'Tuxedo Citrus Co.'),
|
||||
('22222222-2222-2222-2222-222222222222', false, 250.00, false, '456 Citrus Way, Fort Pierce FL', 'Origin Fort Pierce FL', 'orders@indianriverdirect.example','Indian River Direct LLC')
|
||||
ON CONFLICT (brand_id) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. Admin users + Neon Auth users
|
||||
-- ============================================================================
|
||||
INSERT INTO neon_auth.user (id, email, name) VALUES
|
||||
('aaaa1111-1111-1111-1111-111111111111', 'qa-platform@routecomm.example', 'QA Platform Admin'),
|
||||
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'qa-tuxedo@routecomm.example', 'QA Tuxedo Admin'),
|
||||
('cccccccc-cccc-cccc-cccc-cccccccccccc', 'qa-ird@routecomm.example', 'QA IRD Admin')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO admin_users (id, user_id, email, name, role, brand_id,
|
||||
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,
|
||||
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||
can_manage_users, active, display_name)
|
||||
VALUES
|
||||
('aaaa1111-1111-1111-1111-111111111111',
|
||||
'aaaa1111-1111-1111-1111-111111111111',
|
||||
'qa-platform@routecomm.example', 'QA Platform Admin', 'platform_admin', NULL,
|
||||
true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true,
|
||||
'QA Platform Admin'),
|
||||
('bbbb1111-1111-1111-1111-111111111111',
|
||||
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
'qa-tuxedo@routecomm.example', 'QA Tuxedo Admin', 'brand_admin',
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
true, true, true, true, false, false, false, false, false, false, true, false, true, true, false, false, true,
|
||||
'QA Tuxedo Admin'),
|
||||
('cccc1111-1111-1111-1111-111111111111',
|
||||
'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
||||
'qa-ird@routecomm.example', 'QA IRD Admin', 'brand_admin',
|
||||
'22222222-2222-2222-2222-222222222222',
|
||||
true, true, true, true, true, false, true, false, false, false, true, true, true, true, false, false, true,
|
||||
'QA IRD Admin')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO admin_user_brands (admin_user_id, brand_id) VALUES
|
||||
('bbbb1111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111'),
|
||||
('cccc1111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT (admin_user_id, brand_id) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. Products: 500 per brand
|
||||
-- ============================================================================
|
||||
INSERT INTO products (brand_id, name, description, sku, type, price_cents, inventory, unit, active, is_taxable, pickup_type, image_url)
|
||||
SELECT
|
||||
b.id,
|
||||
CASE (i % 50)
|
||||
WHEN 0 THEN 'Grapefruit — Ruby Red #' || i
|
||||
WHEN 1 THEN 'Orange — Valencia #' || i
|
||||
WHEN 2 THEN 'Orange — Navel #' || i
|
||||
WHEN 3 THEN 'Tangerine — Honeybell #' || i
|
||||
WHEN 4 THEN 'Lime — Persian #' || i
|
||||
WHEN 5 THEN 'Lemon — Meyer #' || i
|
||||
WHEN 6 THEN 'Avocado — Hass #' || i
|
||||
WHEN 7 THEN 'Mango — Kent #' || i
|
||||
WHEN 8 THEN 'Strawberry — Albion #' || i
|
||||
WHEN 9 THEN 'Blueberry — Duke #' || i
|
||||
ELSE 'Citrus Mix #' || i
|
||||
END AS name,
|
||||
'Sample description for product #' || i || ' — long enough to wrap on small screens. Unicode: αβγ δεζ 🌿🍊' AS description,
|
||||
'SKU-' || substring(md5(b.id::text || i::text), 1, 8) AS sku,
|
||||
CASE (i % 10) WHEN 0 THEN 'wholesale' WHEN 1 THEN 'both' ELSE 'standard' END,
|
||||
((100 + (i * 7) % 5000))::int,
|
||||
CASE WHEN i % 47 = 0 THEN 0 ELSE (10 + (i * 3) % 200) END,
|
||||
CASE (i % 4) WHEN 0 THEN 'lb' WHEN 1 THEN 'each' WHEN 2 THEN 'box' ELSE 'bushel' END,
|
||||
(i % 23 != 0),
|
||||
(i % 4 = 0),
|
||||
CASE (i % 3) WHEN 0 THEN 'pickup' WHEN 1 THEN 'ship' ELSE 'all' END,
|
||||
'https://placehold.co/600x400?text=P' || i
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 500) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. Stops: 50 per brand
|
||||
-- ============================================================================
|
||||
INSERT INTO stops (brand_id, name, location, address, city, state, zip, date, time, cutoff_date, status, is_public, notes)
|
||||
SELECT
|
||||
b.id,
|
||||
'Stop ' || i || ' — ' || (ARRAY['Downtown','North Park','Westside','East Village','South Bay','Uptown','Riverside','Lakeside'])[1 + (i % 8)],
|
||||
(ARRAY['City Hall Lot','Park & Ride','Community Center','Church Lot','School Lot','Shopping Plaza'])[1 + (i % 6)],
|
||||
(100 + (i * 13) % 9000)::text || ' Main St',
|
||||
(ARRAY['Vero Beach','Fort Pierce','Port St. Lucie','Sebastian','Stuart','Palm Bay'])[1 + (i % 6)],
|
||||
'FL',
|
||||
lpad(((32950 + (i * 7) % 50))::text, 5, '0'),
|
||||
(CURRENT_DATE + ((i - 25) * 7))::date,
|
||||
CASE (i % 4) WHEN 0 THEN '09:00-11:00' WHEN 1 THEN '12:00-14:00' WHEN 2 THEN '15:00-17:00' ELSE '18:00-20:00' END,
|
||||
(CURRENT_DATE + ((i - 25) * 7) - 2)::date,
|
||||
CASE (i % 13) WHEN 0 THEN 'paused' WHEN 1 THEN 'closed' ELSE 'active' END,
|
||||
(i % 7 != 0),
|
||||
CASE WHEN i % 19 = 0 THEN 'Special instructions: bring cash only — unicode ✓' ELSE NULL END
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 50) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 7. Customers: 500 per brand
|
||||
-- ============================================================================
|
||||
INSERT INTO customers (brand_id, primary_email, primary_phone, first_name, last_name, source, metadata)
|
||||
SELECT
|
||||
b.id,
|
||||
'cust' || i || '-' || substring(b.id::text, 1, 4) || '@routecomm.example',
|
||||
CASE WHEN i % 5 = 0 THEN NULL ELSE '+1555' || lpad(((1000000 + i * 37) % 9999999)::text, 7, '0') END,
|
||||
(ARRAY['Alex','Sam','Jordan','Casey','Riley','Morgan','Taylor','Jamie','Avery','Quinn'])[1 + (i % 10)],
|
||||
(ARRAY['Smith','Johnson','Williams','Brown','Jones','Garcia','Miller','Davis','Rodriguez','Martinez'])[1 + ((i/10) % 10)],
|
||||
CASE (i % 6) WHEN 0 THEN 'system' WHEN 1 THEN 'wholesale_application' WHEN 2 THEN 'manual' WHEN 3 THEN 'import' WHEN 4 THEN 'referral' ELSE 'walk_in' END,
|
||||
jsonb_build_object('sms_opt_in', (i % 3 != 0), 'email_opt_in', (i % 9 != 0))
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 500) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 8. Orders: 1000 per brand
|
||||
-- ============================================================================
|
||||
INSERT INTO orders (brand_id, customer_id, stop_id, total_cents, status, fulfillment,
|
||||
customer_address, customer_city, customer_state, customer_zip,
|
||||
idempotency_key, notes, placed_at)
|
||||
SELECT
|
||||
b.id,
|
||||
(SELECT id FROM customers WHERE brand_id = b.id ORDER BY random() LIMIT 1),
|
||||
CASE WHEN i % 3 = 0 THEN NULL
|
||||
ELSE (SELECT id FROM stops WHERE brand_id = b.id AND status='active' ORDER BY random() LIMIT 1) END,
|
||||
CASE WHEN i % 73 = 0 THEN 0 ELSE (500 + (i * 11) % 30000) END,
|
||||
(ARRAY['pending','confirmed','fulfilled','canceled'])[1 + (i % 4)],
|
||||
(ARRAY['pickup','ship','mixed'])[1 + (i % 3)],
|
||||
(100 + (i * 7) % 9000)::text || ' ' ||
|
||||
(ARRAY['Oak','Pine','Maple','Elm','Birch','Cedar'])[1 + (i % 6)] || ' St',
|
||||
(ARRAY['Vero Beach','Fort Pierce','Stuart','Sebastian'])[1 + (i % 4)],
|
||||
'FL',
|
||||
lpad(((32950 + (i * 11) % 50))::text, 5, '0'),
|
||||
'qa-order-' || b.id || '-' || i,
|
||||
CASE WHEN i % 41 = 0 THEN 'Customer note with unicode: gracias 谢谢 🎉' ELSE NULL END,
|
||||
NOW() - ((i % 365) || ' days')::interval
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 1000) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222');
|
||||
-- Note: no ON CONFLICT for orders — idempotency_key has no unique index.
|
||||
-- Re-running this seed after a full db reset is safe; re-running without
|
||||
-- reset will duplicate orders (acceptable for QA-only environment).
|
||||
|
||||
INSERT INTO order_items (order_id, product_id, quantity, price_cents, fulfillment)
|
||||
SELECT
|
||||
o.id,
|
||||
(SELECT id FROM products WHERE brand_id = o.brand_id ORDER BY random() LIMIT 1),
|
||||
(1 + (row_number() OVER (PARTITION BY o.id) % 3)),
|
||||
(500 + (row_number() OVER (PARTITION BY o.id) * 137) % 5000),
|
||||
CASE WHEN o.fulfillment = 'mixed'
|
||||
THEN (ARRAY['pickup','ship'])[1 + (row_number() OVER (PARTITION BY o.id) % 2)]
|
||||
ELSE o.fulfillment END
|
||||
FROM orders o
|
||||
WHERE o.idempotency_key LIKE 'qa-order-%'
|
||||
AND NOT EXISTS (SELECT 1 FROM order_items WHERE order_id = o.id);
|
||||
|
||||
UPDATE orders o
|
||||
SET total_cents = COALESCE((SELECT SUM(quantity * price_cents) FROM order_items WHERE order_id = o.id), 0)
|
||||
WHERE o.idempotency_key LIKE 'qa-order-%';
|
||||
|
||||
-- ============================================================================
|
||||
-- 9. Communication templates + campaigns + contacts + message logs
|
||||
-- ============================================================================
|
||||
INSERT INTO communication_templates (brand_id, name, subject, body_text, body_html, template_type, campaign_type)
|
||||
SELECT b.id, t.name, t.subject, t.body_text, t.body_html, t.template_type, t.campaign_type
|
||||
FROM brands b
|
||||
CROSS JOIN (VALUES
|
||||
('Welcome', 'Welcome to our store!', 'Hello and welcome aboard.', '<p>Hello — welcome aboard.</p>', 'transactional', 'welcome'),
|
||||
('Order Confirmation', 'Your order is confirmed', 'Order #{{order_id}} confirmed.', '<p>Order #{{order_id}} confirmed.</p>', 'transactional', 'order'),
|
||||
('Stop Reminder', 'Pickup tomorrow at {{stop_name}}', 'Don''t forget your pickup tomorrow.', '<p>Don''t forget your pickup tomorrow.</p>', 'transactional', 'reminder'),
|
||||
('Sale Announcement', '🌟 20% off this week', 'Sale ends Friday — unicode ✓.', '<p>Sale ends Friday — unicode ✓.</p>', 'marketing', 'promo'),
|
||||
('Abandoned Cart', 'You left items in your cart', 'Come back and finish your order.', '<p>Come back and finish your order.</p>', 'marketing', 'abandoned_cart')
|
||||
) AS t(name, subject, body_text, body_html, template_type, campaign_type)
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO communication_contacts (brand_id, email, phone, first_name, last_name, sms_opt_in, email_opt_in, source)
|
||||
SELECT
|
||||
b.id,
|
||||
'comm' || i || '-' || substring(b.id::text,1,4) || '@routecomm.example',
|
||||
'+1555' || lpad((1000000 + i * 13)::text, 7, '0'),
|
||||
'Contact' || i,
|
||||
'Last' || i,
|
||||
(i % 3 != 0),
|
||||
(i % 9 != 0),
|
||||
'manual'
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 25) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO communication_campaigns (brand_id, template_id, name, subject, body_text, status, campaign_type)
|
||||
SELECT
|
||||
b.id,
|
||||
(SELECT id FROM communication_templates WHERE brand_id = b.id ORDER BY random() LIMIT 1),
|
||||
'Campaign ' || i || ' — ' || (ARRAY['Welcome push','Holiday sale','Re-engagement','Newsletter','Flash sale','Lapsed buyers','New arrivals','Survey'])[1 + (i % 8)],
|
||||
'Campaign Subject ' || i,
|
||||
'Campaign body ' || i,
|
||||
(ARRAY['draft','scheduled','sending','sent','sent','sent','sent','sent'])[1 + (i % 8)],
|
||||
CASE (i % 4) WHEN 0 THEN 'promo' WHEN 1 THEN 'newsletter' WHEN 2 THEN 'reminder' ELSE 'welcome' END
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 8) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO communication_message_logs (brand_id, campaign_id, contact_id, customer_email, delivery_method, status, subject, body_preview, sent_at)
|
||||
SELECT
|
||||
b.id,
|
||||
(SELECT id FROM communication_campaigns WHERE brand_id = b.id AND status='sent' ORDER BY random() LIMIT 1),
|
||||
(SELECT id FROM communication_contacts WHERE brand_id = b.id ORDER BY random() LIMIT 1),
|
||||
'comm' || (i % 25 + 1) || '-' || substring(b.id::text,1,4) || '@routecomm.example',
|
||||
CASE WHEN i % 5 = 0 THEN 'sms' ELSE 'email' END,
|
||||
(ARRAY['sent','delivered','opened','clicked','bounced','failed'])[1 + (i % 6)],
|
||||
'Subject ' || i,
|
||||
'Body preview ' || i || ' — unicode: 谢谢 ✓',
|
||||
NOW() - ((i % 90) || ' days')::interval
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 200) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 10. Wholesale customers + orders
|
||||
-- ============================================================================
|
||||
INSERT INTO wholesale_customers (brand_id, company_name, contact_name, email, phone, account_status, credit_limit, deposits_enabled, role)
|
||||
SELECT
|
||||
b.id,
|
||||
'Wholesale ' || (ARRAY['Market','Grocers','Foods','Distribs','Co-op','Trading','Imports','Group'])[1 + (i % 8)] || ' #' || i,
|
||||
'Buyer ' || i,
|
||||
'ws' || i || '-' || substring(b.id::text,1,4) || '@routecomm.example',
|
||||
'+1555' || lpad((2000000 + i * 17)::text, 7, '0'),
|
||||
CASE (i % 5) WHEN 0 THEN 'inactive' WHEN 1 THEN 'suspended' ELSE 'active' END,
|
||||
(50000 + (i * 1000) % 500000)::numeric,
|
||||
(i % 3 = 0),
|
||||
'buyer'
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 50) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO wholesale_orders (brand_id, customer_id, status, fulfillment_status, payment_status, subtotal, balance_due, anticipated_pickup_date)
|
||||
SELECT
|
||||
b.id,
|
||||
(SELECT id FROM wholesale_customers WHERE brand_id = b.id ORDER BY random() LIMIT 1),
|
||||
(ARRAY['pending','confirmed','in_production','ready','fulfilled','canceled'])[1 + (i % 6)],
|
||||
(ARRAY['unfulfilled','partial','fulfilled'])[1 + (i % 3)],
|
||||
(ARRAY['unpaid','deposit_paid','paid','refunded'])[1 + (i % 4)],
|
||||
(10.00 + (i * 41) % 500.00)::numeric,
|
||||
(0)::numeric,
|
||||
CURRENT_DATE + ((i % 30))
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 200) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 11. Time tracking infrastructure + logs
|
||||
-- ============================================================================
|
||||
-- Cycle 10: workers are unified into `field_workers`. We seed both the
|
||||
-- former `time_tracking_workers` (role worker|time_admin) and the former
|
||||
-- `water_irrigators` (role irrigator|water_admin) into the same table.
|
||||
INSERT INTO field_workers (id, brand_id, name, role, pin_hash, language_preference, active)
|
||||
SELECT gen_random_uuid(), b.id, 'Worker ' || i, CASE (i%2) WHEN 0 THEN 'worker' ELSE 'time_admin' END, lpad((1000+i)::text, 4, '0'), 'en', true
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 5) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO field_workers (id, brand_id, name, role, pin_hash, language_preference, phone, active)
|
||||
SELECT gen_random_uuid(), b.id, 'Irrigator ' || i, 'irrigator', 'placeholder-pin-hash', 'en', '+15550100000', true
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 5) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO time_tracking_tasks (id, brand_id, name, unit, sort_order, active)
|
||||
SELECT gen_random_uuid(), b.id, 'Task ' || i, CASE (i%3) WHEN 0 THEN 'hours' WHEN 1 THEN 'pieces' ELSE 'units' END, i, true
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 8) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO time_tracking_logs (brand_id, field_worker_id, task_id, task_name, clock_in, clock_out, lunch_break_minutes, notes)
|
||||
SELECT
|
||||
b.id,
|
||||
(SELECT id FROM field_workers WHERE brand_id = b.id AND role IN ('worker','time_admin') ORDER BY random() LIMIT 1),
|
||||
(SELECT id FROM time_tracking_tasks WHERE brand_id = b.id ORDER BY random() LIMIT 1),
|
||||
'Task ' || (1 + (i % 8)),
|
||||
(NOW() - ((i % 30) || ' days')::interval - ((i % 8) || ' hours')::interval),
|
||||
(NOW() - ((i % 30) || ' days')::interval - ((i % 8) || ' hours')::interval + interval '90 minutes'),
|
||||
CASE WHEN i % 4 = 0 THEN 30 ELSE 0 END,
|
||||
'QA generated log entry #' || i
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 200) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 12. Water log infrastructure + entries
|
||||
-- ============================================================================
|
||||
INSERT INTO water_headgates (id, brand_id, name, max_flow_gpm, unit, status, active)
|
||||
SELECT gen_random_uuid(), b.id, 'Headgate ' || i, 1000 + i * 100, 'GPM', 'open', true
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 5) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO water_log_entries (brand_id, headgate_id, field_worker_id, measurement, unit, logged_at, logged_by, method, total_gallons, notes)
|
||||
SELECT
|
||||
b.id,
|
||||
(SELECT id FROM water_headgates WHERE brand_id = b.id ORDER BY random() LIMIT 1),
|
||||
(SELECT id FROM field_workers WHERE brand_id = b.id AND role IN ('irrigator','water_admin') ORDER BY random() LIMIT 1),
|
||||
(50 + (i * 7) % 200)::numeric,
|
||||
'GPM',
|
||||
NOW() - ((i % 14) || ' days')::interval,
|
||||
'aaaa1111-1111-1111-1111-111111111111',
|
||||
'manual',
|
||||
(1000 + (i * 23) % 5000)::numeric,
|
||||
'Auto-generated water log #' || i
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 100) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 13. Audit log entries
|
||||
-- ============================================================================
|
||||
INSERT INTO audit_logs (brand_id, user_id, action, entity_type, entity_id, details, ip_address, created_at)
|
||||
SELECT
|
||||
b.id,
|
||||
'aaaa1111-1111-1111-1111-111111111111',
|
||||
(ARRAY['create','update','delete','view','export'])[1 + (i % 5)],
|
||||
(ARRAY['products','orders','stops','customers'])[1 + (i % 4)],
|
||||
gen_random_uuid(),
|
||||
jsonb_build_object('qa', true, 'iteration', i),
|
||||
'10.0.0.' || (1 + (i % 254)),
|
||||
NOW() - ((i % 90) || ' days')::interval
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 50) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================================================
|
||||
-- Summary
|
||||
-- ============================================================================
|
||||
SELECT 'products' AS tbl, COUNT(*) FROM products UNION ALL
|
||||
SELECT 'stops', COUNT(*) FROM stops UNION ALL
|
||||
SELECT 'customers', COUNT(*) FROM customers UNION ALL
|
||||
SELECT 'orders', COUNT(*) FROM orders UNION ALL
|
||||
SELECT 'order_items', COUNT(*) FROM order_items UNION ALL
|
||||
SELECT 'wholesale_customers', COUNT(*) FROM wholesale_customers UNION ALL
|
||||
SELECT 'wholesale_orders', COUNT(*) FROM wholesale_orders UNION ALL
|
||||
SELECT 'comm_templates', COUNT(*) FROM communication_templates UNION ALL
|
||||
SELECT 'comm_campaigns', COUNT(*) FROM communication_campaigns UNION ALL
|
||||
SELECT 'comm_contacts', COUNT(*) FROM communication_contacts UNION ALL
|
||||
SELECT 'comm_message_logs', COUNT(*) FROM communication_message_logs UNION ALL
|
||||
SELECT 'time_tracking_logs', COUNT(*) FROM time_tracking_logs UNION ALL
|
||||
SELECT 'water_log_entries', COUNT(*) FROM water_log_entries UNION ALL
|
||||
SELECT 'audit_logs', COUNT(*) FROM audit_logs UNION ALL
|
||||
SELECT 'brands', COUNT(*) FROM brands UNION ALL
|
||||
SELECT 'admin_users', COUNT(*) FROM admin_users
|
||||
ORDER BY tbl;
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
Executable
+429
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# deploy.sh — Idempotent PostgREST + Next.js production deploy
|
||||
# =============================================================================
|
||||
#
|
||||
# Self-hosted single-server deploy. Triggered manually, by Gitea webhook, or
|
||||
# by a Gitea Actions runner after a push to `main` (or `gitea-sync`).
|
||||
#
|
||||
# What it does, in order:
|
||||
# 1. Acquires an exclusive flock (concurrent deploys die loudly).
|
||||
# 2. CLEANUP: stops the dev stack on :3001 and the previous prod stack
|
||||
# (port read from .postgrest-port / .nextjs-port).
|
||||
# 3. PORT_SELECTION: picks the lowest free port in [3011..30200] for
|
||||
# PostgREST, then the next free one for the Next.js frontend.
|
||||
# 4. BUILD: runs `npm run build` with NEXT_PUBLIC_API_URL exported so it
|
||||
# gets inlined into the client bundle.
|
||||
# 5. DEPLOY: writes the chosen ports to .env.production, brings the
|
||||
# compose stack up.
|
||||
# 6. NGINX: renders the nginx config from a template (with the current
|
||||
# ports), `nginx -t`s it, and reloads the host systemd nginx.
|
||||
# 7. HEALTHCHECK: curls the new stack; if anything is down, rolls back.
|
||||
# 8. IMAGE_PRUNE: optional, removes dangling images on success.
|
||||
#
|
||||
# Files written to the workspace root:
|
||||
# .postgrest-port current PostgREST host port (atomic)
|
||||
# .nextjs-port current Next.js host port (atomic)
|
||||
# .env.production rendered env fed to docker compose
|
||||
# .deploy.lock flock target
|
||||
# deploy.log append-only log
|
||||
# =============================================================================
|
||||
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configurable variables (override via environment before invoking)
|
||||
# -----------------------------------------------------------------------------
|
||||
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-${WORKSPACE}/deploy}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-${COMPOSE_DIR}/docker-compose.yml}"
|
||||
NGINX_TEMPLATE="${NGINX_TEMPLATE:-${COMPOSE_DIR}/nginx.conf.template}"
|
||||
NGINX_RENDERED="${NGINX_RENDERED:-/etc/nginx/sites-available/prod-app.conf}"
|
||||
NGINX_LINK="${NGINX_LINK:-/etc/nginx/sites-enabled/prod-app.conf}"
|
||||
NGINX_OWNER="${NGINX_OWNER:-www-data:www-data}"
|
||||
|
||||
PROJECT_NAME="${PROJECT_NAME:-prod-app}"
|
||||
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
|
||||
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
|
||||
ENV_FILE="${ENV_FILE:-${WORKSPACE}/.env.production}"
|
||||
LOCK_FILE="${LOCK_FILE:-${WORKSPACE}/.deploy.lock}"
|
||||
LOG_FILE="${LOG_FILE:-${WORKSPACE}/deploy.log}"
|
||||
|
||||
DEV_PORT="${DEV_PORT:-3001}"
|
||||
PORT_RANGE_START="${PORT_RANGE_START:-3011}"
|
||||
PORT_RANGE_END="${PORT_RANGE_END:-30200}"
|
||||
HEALTHCHECK_TIMEOUT="${HEALTHCHECK_TIMEOUT:-60}" # seconds total
|
||||
HEALTHCHECK_INTERVAL="${HEALTHCHECK_INTERVAL:-2}" # seconds between tries
|
||||
|
||||
# Image pruning (set PRUNE_IMAGES=0 to skip)
|
||||
PRUNE_IMAGES="${PRUNE_IMAGES:-1}"
|
||||
|
||||
# Optional: pin the public URL the browser uses. If empty, we default to
|
||||
# http://localhost:${POSTGREST_HOST_PORT}. For production with a real domain
|
||||
# and nginx in front, set e.g. NEXT_PUBLIC_API_URL=https://app.example.com/api
|
||||
NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging — every line is timestamped, tee'd to stdout AND the log file.
|
||||
# We replace the shell's fd 1/2 with a tee so any tool that prints (npm, docker,
|
||||
# curl) lands in both places automatically.
|
||||
# -----------------------------------------------------------------------------
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||
|
||||
ts() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||
log() { printf '[%s] %s\n' "$(ts)" "$*"; }
|
||||
hr() { printf '%s\n' '----------------------------------------------------------------'; }
|
||||
section() { hr; log "== $* =="; hr; }
|
||||
|
||||
# Trap so we always release the lock and surface a useful message.
|
||||
on_exit() {
|
||||
local exit_code=$?
|
||||
if (( exit_code != 0 )); then
|
||||
log "DEPLOY FAILED with exit code ${exit_code}"
|
||||
log "See ${LOG_FILE} for full output. Rollback hints:"
|
||||
log " - Previous port was: ${PREVIOUS_POSTGREST_PORT:-<unknown>}"
|
||||
log " - Current .postgrest-port value: $(read_port_file "$POSTGREST_PORT_FILE" || echo '<none>')"
|
||||
log " - To restart the old stack manually:"
|
||||
log " POSTGREST_HOST_PORT=${PREVIOUS_POSTGREST_PORT:-3011} \\"
|
||||
log " NEXTJS_HOST_PORT=${PREVIOUS_NEXTJS_PORT:-3012} \\"
|
||||
log " docker compose -p ${PROJECT_NAME} --env-file ${ENV_FILE} up -d"
|
||||
else
|
||||
log "DEPLOY OK — PostgREST on :${NEW_POSTGREST_PORT}, Next.js on :${NEW_NEXTJS_PORT}"
|
||||
fi
|
||||
# flock on fd 9 releases automatically when the script exits.
|
||||
}
|
||||
trap on_exit EXIT
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
read_port_file() {
|
||||
# Echo the port in $1, or empty string if missing/garbage.
|
||||
local f="$1"
|
||||
[[ -f "$f" ]] || return 1
|
||||
local v
|
||||
v=$(tr -d '[:space:]' < "$f" 2>/dev/null || true)
|
||||
[[ "$v" =~ ^[0-9]+$ ]] || return 1
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
render_template() {
|
||||
# Portable envsubst: replaces $VAR and ${VAR} references in stdin with
|
||||
# values from the current environment. Only the variable names given as
|
||||
# args are expanded (matches `envsubst` behavior). If real envsubst is
|
||||
# available we use it for speed.
|
||||
local vars="$1"
|
||||
if command -v envsubst >/dev/null 2>&1; then
|
||||
envsubst "$vars"
|
||||
else
|
||||
# Build a sed expression like: s/\${VAR}/$VAR/g; s/\bVAR\b/$VAR/g
|
||||
local sed_expr=()
|
||||
for v in $vars; do
|
||||
v="${v#\$}"
|
||||
v="${v#\{}"
|
||||
v="${v%\}}"
|
||||
sed_expr+=( -e "s|\${${v}}|${!v:-}|g" )
|
||||
sed_expr+=( -e "s|\$${v}\b|${!v:-}|g" )
|
||||
done
|
||||
sed "${sed_expr[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
is_listening() {
|
||||
# Returns 0 if port $1 has a TCP listener (v4 or v6) on this host.
|
||||
local port="$1"
|
||||
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${port}$"
|
||||
}
|
||||
|
||||
next_free_port() {
|
||||
# Walk PORT_RANGE_START..PORT_RANGE_END and return the first port nobody
|
||||
# is listening on. Returns 1 if none are free.
|
||||
local p
|
||||
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
|
||||
if ! is_listening "$p"; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
atomic_write() {
|
||||
# Write stdin to $1 atomically: write to temp, fsync, rename. This is
|
||||
# what lets us use .postgrest-port as a single source of truth — readers
|
||||
# always see either the old value or the new value, never a half-written one.
|
||||
local target="$1"
|
||||
local tmp
|
||||
tmp=$(mktemp "${target}.tmp.XXXXXX")
|
||||
cat > "$tmp"
|
||||
sync
|
||||
mv -f "$tmp" "$target"
|
||||
}
|
||||
|
||||
free_port() {
|
||||
# Try several strategies to free a port:
|
||||
# 1. docker compose down for our project (idempotent)
|
||||
# 2. brute-force kill of any process bound to the port
|
||||
local port="$1" label="$2"
|
||||
if [[ -z "$port" ]]; then return 0; fi
|
||||
log " ${label} port ${port}: stopping project '${PROJECT_NAME}' (if up)"
|
||||
( cd "$COMPOSE_DIR" && docker compose -p "$PROJECT_NAME" down --remove-orphans --timeout 10 ) \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
if is_listening "$port"; then
|
||||
log " ${label} port ${port}: still listening, attempting pkill"
|
||||
# fuser prints PIDs holding the port; xargs kills them.
|
||||
local pids
|
||||
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
|
||||
if [[ -n "$pids" ]]; then
|
||||
# shellcheck disable=SC2086
|
||||
kill $pids 2>/dev/null || true
|
||||
sleep 1
|
||||
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
|
||||
[[ -n "$pids" ]] && kill -9 $pids 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
if is_listening "$port"; then
|
||||
log " ${label} port ${port}: WARNING — still in use after cleanup"
|
||||
return 1
|
||||
fi
|
||||
log " ${label} port ${port}: free"
|
||||
return 0
|
||||
}
|
||||
|
||||
healthcheck() {
|
||||
# Hit $1 (URL) until it returns 2xx within HEALTHCHECK_TIMEOUT seconds.
|
||||
local url="$1" label="$2" elapsed=0
|
||||
log " ${label}: ${url}"
|
||||
while (( elapsed < HEALTHCHECK_TIMEOUT )); do
|
||||
if curl -fsS --max-time 5 -o /dev/null "$url"; then
|
||||
log " ${label}: OK (after ${elapsed}s)"
|
||||
return 0
|
||||
fi
|
||||
sleep "$HEALTHCHECK_INTERVAL"
|
||||
elapsed=$(( elapsed + HEALTHCHECK_INTERVAL ))
|
||||
done
|
||||
log " ${label}: FAILED after ${HEALTHCHECK_TIMEOUT}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Lock — refuse to run if another deploy is in flight.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "LOCK"
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
log "Another deploy holds ${LOCK_FILE}. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
log "Acquired exclusive lock on ${LOCK_FILE}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 0. Banner
|
||||
# -----------------------------------------------------------------------------
|
||||
section "DEPLOY START"
|
||||
log "Workspace: ${WORKSPACE}"
|
||||
log "Project: ${PROJECT_NAME}"
|
||||
log "Compose: ${COMPOSE_FILE}"
|
||||
log "Nginx tpl: ${NGINX_TEMPLATE}"
|
||||
log "Port range: ${PORT_RANGE_START}..${PORT_RANGE_END}"
|
||||
log "Caller: ${USER:-<unknown>}@$(hostname)"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 1. CLEANUP — port 3001 (dev) and the previous prod ports.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "CLEANUP"
|
||||
|
||||
free_port "$DEV_PORT" "dev"
|
||||
PREVIOUS_POSTGREST_PORT=$(read_port_file "$POSTGREST_PORT_FILE" || true)
|
||||
PREVIOUS_NEXTJS_PORT=$(read_port_file "$NEXTJS_PORT_FILE" || true)
|
||||
log "Previous prod ports: PostgREST=${PREVIOUS_POSTGREST_PORT:-<none>} Next.js=${PREVIOUS_NEXTJS_PORT:-<none>}"
|
||||
|
||||
# Stale-port guard: if the file points to a port that is NOT in our standard
|
||||
# range, or to a port that nothing is listening on anymore, we still tear
|
||||
# down the project (cheap) but we don't try to free the port itself —
|
||||
# someone else might be using it.
|
||||
free_port "${PREVIOUS_POSTGREST_PORT:-}" "prev-postgrest"
|
||||
free_port "${PREVIOUS_NEXTJS_PORT:-}" "prev-nextjs"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 2. PORT_SELECTION — find the two lowest free ports.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "PORT_SELECTION"
|
||||
|
||||
NEW_POSTGREST_PORT=$(next_free_port) || {
|
||||
log "No free port in [${PORT_RANGE_START}..${PORT_RANGE_END}]. Bailing out."
|
||||
exit 2
|
||||
}
|
||||
log "PostgREST: ${NEW_POSTGREST_PORT}"
|
||||
|
||||
# Re-check after allocation, since we want distinct ports for both services.
|
||||
NEW_NEXTJS_PORT=""
|
||||
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
|
||||
if (( p == NEW_POSTGREST_PORT )); then continue; fi
|
||||
if ! is_listening "$p"; then NEW_NEXTJS_PORT="$p"; break; fi
|
||||
done
|
||||
if [[ -z "$NEW_NEXTJS_PORT" ]]; then
|
||||
log "No free port for Next.js after allocating ${NEW_POSTGREST_PORT}. Bailing out."
|
||||
exit 2
|
||||
fi
|
||||
log "Next.js: ${NEW_NEXTJS_PORT}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 3. BUILD — Next.js, with NEXT_PUBLIC_API_URL inlined into the client bundle.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "BUILD"
|
||||
|
||||
cd "$WORKSPACE"
|
||||
|
||||
# Default the public API URL the browser will see.
|
||||
if [[ -z "$NEXT_PUBLIC_API_URL" ]]; then
|
||||
NEXT_PUBLIC_API_URL="http://localhost:${NEW_POSTGREST_PORT}"
|
||||
fi
|
||||
log "NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}"
|
||||
|
||||
# Node-only check: don't try to build if there's no package.json.
|
||||
if [[ -f package.json ]]; then
|
||||
# Make sure the deps are present (idempotent — npm ci is a no-op when locked).
|
||||
if [[ -f package-lock.json ]]; then
|
||||
log "npm ci (locked install)"
|
||||
npm ci --no-audit --no-fund
|
||||
else
|
||||
log "npm install (no lockfile present — consider committing package-lock.json)"
|
||||
npm install --no-audit --no-fund
|
||||
fi
|
||||
log "npm run build"
|
||||
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
|
||||
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
|
||||
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
|
||||
npm run build
|
||||
else
|
||||
log "No package.json in ${WORKSPACE} — skipping build step."
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 4. ENV FILE — render .env.production for the running containers.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "ENV"
|
||||
|
||||
# Preserve any pre-existing secrets in .env.production. We only own the lines
|
||||
# we write; everything else is left alone. (The simplest sane strategy.)
|
||||
SECRETS_FILE=""
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
SECRETS_FILE=$(mktemp)
|
||||
# Drop any lines we manage; keep the rest verbatim.
|
||||
grep -v -E '^(POSTGREST_HOST_PORT|NEXTJS_HOST_PORT|NEXT_PUBLIC_API_URL)=' \
|
||||
"$ENV_FILE" > "$SECRETS_FILE" || true
|
||||
fi
|
||||
|
||||
{
|
||||
printf '# Generated by deploy.sh on %s — safe to edit, lines below are managed\n' "$(ts)"
|
||||
printf 'POSTGREST_HOST_PORT=%s\n' "$NEW_POSTGREST_PORT"
|
||||
printf 'NEXTJS_HOST_PORT=%s\n' "$NEW_NEXTJS_PORT"
|
||||
printf 'NEXT_PUBLIC_API_URL=%q\n' "$NEXT_PUBLIC_API_URL"
|
||||
if [[ -n "$SECRETS_FILE" ]]; then
|
||||
cat "$SECRETS_FILE"
|
||||
rm -f "$SECRETS_FILE"
|
||||
fi
|
||||
} > "${ENV_FILE}.new"
|
||||
|
||||
mv -f "${ENV_FILE}.new" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
log "Wrote ${ENV_FILE}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 5. DEPLOY — bring the stack up.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "DEPLOY"
|
||||
|
||||
cd "$COMPOSE_DIR"
|
||||
log "docker compose -p ${PROJECT_NAME} up -d --build"
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --build
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 6. NGINX — render config from template, test, reload.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "NGINX"
|
||||
|
||||
if [[ -f "$NGINX_TEMPLATE" ]]; then
|
||||
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
|
||||
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
|
||||
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
|
||||
render_template '${POSTGREST_HOST_PORT} ${NEXTJS_HOST_PORT} ${NEXT_PUBLIC_API_URL}' \
|
||||
< "$NGINX_TEMPLATE" > "$NGINX_RENDERED"
|
||||
|
||||
log "Rendered: ${NGINX_RENDERED}"
|
||||
chown "$NGINX_OWNER" "$NGINX_RENDERED" 2>/dev/null || true
|
||||
chmod 644 "$NGINX_RENDERED"
|
||||
|
||||
# Wire it into sites-enabled if not already linked.
|
||||
if [[ ! -L "$NGINX_LINK" && ! -e "$NGINX_LINK" ]]; then
|
||||
log "Enabling site: ${NGINX_LINK} -> ${NGINX_RENDERED}"
|
||||
ln -s "$NGINX_RENDERED" "$NGINX_LINK"
|
||||
fi
|
||||
|
||||
log "nginx -t"
|
||||
nginx -t
|
||||
log "systemctl reload nginx"
|
||||
systemctl reload nginx
|
||||
else
|
||||
log "No nginx template at ${NGINX_TEMPLATE} — skipping reverse proxy step."
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 7. HEALTHCHECK — direct + via nginx (when applicable).
|
||||
# -----------------------------------------------------------------------------
|
||||
section "HEALTHCHECK"
|
||||
|
||||
# Direct checks (bypass nginx, catch compose issues)
|
||||
healthcheck "http://127.0.0.1:${NEW_POSTGREST_PORT}/" "postgrest-direct" || ROLLBACK=1
|
||||
healthcheck "http://127.0.0.1:${NEW_NEXTJS_PORT}/" "nextjs-direct" || ROLLBACK=1
|
||||
|
||||
# nginx-fronted check (only meaningful if nginx template exists)
|
||||
if [[ -f "$NGINX_TEMPLATE" && "${ROLLBACK:-0}" != "1" ]]; then
|
||||
healthcheck "http://127.0.0.1/" "nginx-front" || ROLLBACK=1
|
||||
fi
|
||||
|
||||
if [[ "${ROLLBACK:-0}" == "1" ]]; then
|
||||
log "HEALTHCHECK FAILED — rolling back."
|
||||
log "Tearing down the new stack on :${NEW_POSTGREST_PORT} / :${NEW_NEXTJS_PORT}"
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" down --remove-orphans --timeout 10 || true
|
||||
|
||||
# If we had a previous port file, the old one is still on disk (we wrote
|
||||
# the new one to .new and only mv'd on success... but we DID mv already,
|
||||
# so re-write the old value).
|
||||
if [[ -n "${PREVIOUS_POSTGREST_PORT:-}" ]]; then
|
||||
printf '%s\n' "$PREVIOUS_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
|
||||
else
|
||||
rm -f "$POSTGREST_PORT_FILE"
|
||||
fi
|
||||
if [[ -n "${PREVIOUS_NEXTJS_PORT:-}" ]]; then
|
||||
printf '%s\n' "$PREVIOUS_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
|
||||
else
|
||||
rm -f "$NEXTJS_PORT_FILE"
|
||||
fi
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 8. PERSIST — commit the chosen ports as the new single source of truth.
|
||||
# (Done AFTER healthcheck so a failed deploy doesn't clobber the old one.)
|
||||
# -----------------------------------------------------------------------------
|
||||
section "PERSIST"
|
||||
printf '%s\n' "$NEW_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
|
||||
printf '%s\n' "$NEW_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
|
||||
log ".postgrest-port = ${NEW_POSTGREST_PORT}"
|
||||
log ".nextjs-port = ${NEW_NEXTJS_PORT}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 9. IMAGE_PRUNE — optional housekeeping.
|
||||
# -----------------------------------------------------------------------------
|
||||
if [[ "$PRUNE_IMAGES" == "1" ]]; then
|
||||
section "IMAGE_PRUNE"
|
||||
docker image prune -f
|
||||
fi
|
||||
|
||||
section "DONE"
|
||||
exit 0
|
||||
@@ -0,0 +1,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
|
||||
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# healthcheck.sh — standalone, callable from cron / monitoring
|
||||
# =============================================================================
|
||||
#
|
||||
# Reads the current prod ports from .postgrest-port / .nextjs-port and curls
|
||||
# each service. Exit code is the count of failed checks (0 = all healthy).
|
||||
#
|
||||
# Usage:
|
||||
# ./healthcheck.sh
|
||||
# ./healthcheck.sh --nginx # also check the fronted URL
|
||||
# WORKSPACE=/srv/app ./healthcheck.sh
|
||||
# =============================================================================
|
||||
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
|
||||
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
|
||||
TIMEOUT="${HEALTHCHECK_TIMEOUT:-5}"
|
||||
|
||||
failures=0
|
||||
|
||||
check() {
|
||||
local label="$1" url="$2"
|
||||
if curl -fsS --max-time "$TIMEOUT" -o /dev/null "$url"; then
|
||||
printf ' [ OK ] %-20s %s\n' "$label" "$url"
|
||||
else
|
||||
printf ' [FAIL] %-20s %s\n' "$label" "$url"
|
||||
failures=$(( failures + 1 ))
|
||||
fi
|
||||
}
|
||||
|
||||
pgrest_port=$(tr -d '[:space:]' < "$POSTGREST_PORT_FILE" 2>/dev/null || echo "")
|
||||
next_port=$(tr -d '[:space:]' < "$NEXTJS_PORT_FILE" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$pgrest_port" ]]; then
|
||||
check "postgrest" "http://127.0.0.1:${pgrest_port}/"
|
||||
else
|
||||
printf ' [SKIP] postgrest (no .postgrest-port)\n'
|
||||
fi
|
||||
|
||||
if [[ -n "$next_port" ]]; then
|
||||
check "nextjs" "http://127.0.0.1:${next_port}/"
|
||||
else
|
||||
printf ' [SKIP] nextjs (no .nextjs-port)\n'
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "--nginx" ]]; then
|
||||
check "nginx" "http://127.0.0.1/"
|
||||
fi
|
||||
|
||||
exit "$failures"
|
||||
@@ -0,0 +1,89 @@
|
||||
# =============================================================================
|
||||
# nginx.conf.template — rendered by deploy.sh on every deploy
|
||||
# =============================================================================
|
||||
#
|
||||
# Variables substituted by `envsubst`:
|
||||
# ${POSTGREST_HOST_PORT} dynamic host port of the PostgREST container
|
||||
# ${NEXTJS_HOST_PORT} dynamic host port of the Next.js container
|
||||
# ${NEXT_PUBLIC_API_URL} (informational only — used in comment header)
|
||||
#
|
||||
# Layout:
|
||||
# /api/* -> http://127.0.0.1:${POSTGREST_HOST_PORT}
|
||||
# /* -> http://127.0.0.1:${NEXTJS_HOST_PORT}
|
||||
#
|
||||
# Tested against nginx >= 1.18 (Debian 11 / Ubuntu 22.04). Adjust ssl_*
|
||||
# lines if you don't have a cert yet — deploy.sh only tests/renders, the
|
||||
# operator decides whether to terminate TLS here.
|
||||
# =============================================================================
|
||||
|
||||
# --- upstream definitions ---------------------------------------------------
|
||||
upstream postgrest_upstream {
|
||||
server 127.0.0.1:${POSTGREST_HOST_PORT};
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
upstream nextjs_upstream {
|
||||
server 127.0.0.1:${NEXTJS_HOST_PORT};
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
# --- HTTP -> HTTPS upgrade (optional; remove if you only run on LAN) --------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
# ACME http-01 challenge needs to be served on port 80.
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/letsencrypt;
|
||||
}
|
||||
|
||||
# Redirect everything else to HTTPS. Comment out for plain-HTTP dev.
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# --- main server block ------------------------------------------------------
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name _;
|
||||
|
||||
# --- TLS (uncomment + adjust after you obtain a cert) ------------------
|
||||
# ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# --- sensible defaults ------------------------------------------------
|
||||
client_max_body_size 25m;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# --- API: /api/* -> PostgREST ----------------------------------------
|
||||
location /api/ {
|
||||
proxy_pass http://postgrest_upstream;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
|
||||
# PostgREST exposes its OpenAPI spec at the root of the API; expose it
|
||||
# under a stable URL too.
|
||||
location = /api {
|
||||
proxy_pass http://postgrest_upstream;
|
||||
}
|
||||
|
||||
# --- everything else -> Next.js --------------------------------------
|
||||
location / {
|
||||
proxy_pass http://nextjs_upstream;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,671 @@
|
||||
# Acceptance Criteria — 2026-06-25
|
||||
|
||||
> Format per item: **AC-NN.SS** (module.section) — Given/When/Then or assertion. Edge cases follow each AC block prefixed `EC-NN.SS.x`.
|
||||
|
||||
## Risk tier definitions
|
||||
|
||||
- **C — Critical**: any data loss, RBAC bypass, payment error, auth failure, or cross-tenant data leak. **Must** pass before sign-off.
|
||||
- **H — High**: core CRUD flows used daily by admin/brand_admin. Pass-blocking.
|
||||
- **M — Medium**: secondary features (filters, exports, analytics). Pass-blocking for release quality, acceptable for hot-fix.
|
||||
- **L — Low**: cosmetics, marketing pages, static content. Best-effort.
|
||||
|
||||
---
|
||||
|
||||
## ROLES & IDENTITY (C)
|
||||
|
||||
### AC-R1 — dev_session cookie impersonates roles in dev mode
|
||||
- **Given** NODE_ENV !== "production"
|
||||
- **When** request carries `Cookie: dev_session=platform_admin`
|
||||
- **Then** `getAdminUser()` returns `{ role: 'platform_admin', brand_id: null, brandIds: [...], email: 'qa-platform@...' }`
|
||||
- **And** every `/admin/*` route renders without `<AdminAccessDenied />`
|
||||
|
||||
EC-R1:
|
||||
- EC-R1.1: invalid value (`dev_session=hacker`) → returns null (no impersonation)
|
||||
- EC-R1.2: missing cookie → null → `/admin/*` shows Access Denied
|
||||
- EC-R1.3: NODE_ENV=production → dev_session is ignored, real Neon Auth required
|
||||
|
||||
### AC-R2 — RBAC enforcement
|
||||
- **Given** admin user with `can_manage_orders=false`
|
||||
- **When** visiting `/admin/orders/[id]`
|
||||
- **Then** redirect to `/admin/pickup`
|
||||
- **And** `/admin/orders` shows Access Denied or redirects similarly
|
||||
|
||||
EC-R2:
|
||||
- EC-R2.1: any permission flag `can_manage_X=false` enforces redirect on `/admin/X`
|
||||
- EC-R2.2: all flags `true` → no redirects
|
||||
- EC-R2.3: brand_admin without brand link → null user → Access Denied
|
||||
|
||||
### AC-R3 — Tenant scoping
|
||||
- **Given** brand_admin of brand A
|
||||
- **When** calling RPCs with `p_brand_id=brand_A_id`
|
||||
- **Then** only brand A rows are returned
|
||||
- **When** calling with `p_brand_id=brand_B_id`
|
||||
- **Then** zero rows or 403/empty depending on the action
|
||||
|
||||
EC-R3:
|
||||
- EC-R3.1: platform_admin with `p_brand_id=null` returns all brands
|
||||
- EC-R3.2: brand_admin calling with `p_brand_id=null` falls back to their `brand_id`
|
||||
- EC-R3.3: brand_admin calling with `p_brand_id=other_brand_id` → zero/empty
|
||||
|
||||
---
|
||||
|
||||
## ADMIN SHELL (C)
|
||||
|
||||
### AC-A1 — `/admin/page.tsx` redirects to `/admin/v2`
|
||||
- **Then** 307 → `/admin/v2`
|
||||
|
||||
### AC-A2 — `/admin/v2` dashboard renders for platform_admin
|
||||
- **Then** shows "Today" heading + stat cards + stops + orders
|
||||
- **And** no Access Denied
|
||||
|
||||
EC-A2:
|
||||
- EC-A2.1: 0 stops today → "No stops scheduled today" empty state
|
||||
- EC-A2.2: 0 pending orders → empty state
|
||||
- EC-A2.3: activeBrandId null → "No brand selected" empty state
|
||||
|
||||
### AC-A3 — AdminSidebar nav links navigate correctly
|
||||
- **Then** every Workspace/Operations/Communications/Growth/Tracking/Insights/Settings link navigates to its target route without error
|
||||
|
||||
EC-A3:
|
||||
- EC-A3.1: Tracking → Water Log hidden when `enabledAddons["water_log"]=false`
|
||||
- EC-A3.2: Tracking → Route Trace hidden when `enabledAddons["route_trace"]=false`
|
||||
- EC-A3.3: `requiresPlatformAdmin` items hidden for non-platform users
|
||||
- EC-A3.4: mobile menu closes on link click
|
||||
- EC-A3.5: ESC closes mobile menu
|
||||
- EC-A3.6: focus moves to close button when mobile menu opens
|
||||
- EC-A3.7: arrow-key navigation loops top-to-bottom-to-top
|
||||
|
||||
### AC-A4 — MobileTabBar navigates and shows active state
|
||||
- **Then** clicking each tab navigates + highlights active tab matching `pathname`
|
||||
|
||||
EC-A4:
|
||||
- EC-A4.1: clicking More opens MoreSheet
|
||||
- EC-A4.2: clicking a MoreSheet link closes sheet and navigates
|
||||
|
||||
### AC-A5 — CommandPalette opens on Cmd/Ctrl+K
|
||||
- **Then** modal appears, search input focused
|
||||
|
||||
EC-A5:
|
||||
- EC-A5.1: ↑↓ navigation, Enter selects, ESC closes
|
||||
- EC-A5.2: body scroll locked while open
|
||||
- EC-A5.3: backdrop click closes
|
||||
- EC-A5.4: empty results → "No results for 'query'"
|
||||
- EC-A5.5: navigate via result updates URL and closes palette
|
||||
|
||||
### AC-A6 — BrandSelector switches active brand
|
||||
- **Then** selecting a brand sets cookie + refreshes page; `BrandSelector` reflects new active
|
||||
|
||||
EC-A6:
|
||||
- EC-A6.1: "All brands" option visible only to platform_admin
|
||||
- EC-A6.2: multi-brand admin → "multi" badge
|
||||
- EC-A6.3: outside click closes
|
||||
- EC-A6.4: ESC closes
|
||||
|
||||
### AC-A7 — OfflineBanner shows when offline or pending
|
||||
- **Then** offline → danger-soft banner; pending sync → warning-soft banner
|
||||
- **When** online + 0 pending → banner hidden
|
||||
|
||||
EC-A7:
|
||||
- EC-A7.1: pre-mount → null (no hydration mismatch)
|
||||
|
||||
---
|
||||
|
||||
## ORDERS (C)
|
||||
|
||||
### AC-M1.1 — `/admin/orders` legacy list loads
|
||||
- **Then** status tabs (all/pending/picked_up) render with counts
|
||||
- **And** table shows orders sorted by placed_at desc
|
||||
|
||||
EC-M1.1:
|
||||
- EC-M1.1.1: empty state when 0 orders → "No orders yet" + Create CTA
|
||||
- EC-M1.1.2: filter by status → list filters correctly
|
||||
- EC-M1.1.3: search by customer name → matches substring
|
||||
- EC-M1.1.4: search by phone → matches digits
|
||||
- EC-M1.1.5: search by order id prefix → matches
|
||||
- EC-M1.1.6: pagination next/prev changes page
|
||||
- EC-M1.1.7: stop filter multi-select → list filtered by selected stops
|
||||
- EC-M1.1.8: clearing filters shows all orders
|
||||
- EC-M1.1.9: bulk select + Mark All Picked Up → all selected marked, toast shows counts
|
||||
- EC-M1.1.10: partial bulk failure → some succeed, others revert, error toast
|
||||
- EC-M1.1.11: `?new=true` opens modal on mount
|
||||
- EC-M1.1.12: row click navigates to `/admin/orders/[id]`
|
||||
|
||||
### AC-M1.2 — `/admin/orders/new` modal
|
||||
- **Then** modal opens with required: customer name; optional: email, phone, stop
|
||||
- **And** items table with at least 1 row
|
||||
|
||||
EC-M1.2:
|
||||
- EC-M1.2.1: empty customer name → submission blocked
|
||||
- EC-M1.2.2: 0 items → Create Order disabled
|
||||
- EC-M1.2.3: invalid price (< 0 or non-numeric) → submission blocked
|
||||
- EC-M1.2.4: qty < 1 → blocked
|
||||
- EC-M1.2.5: success → full-page reload to `/admin/orders`, modal closes
|
||||
- EC-M1.2.6: server error → red error banner inside modal
|
||||
- EC-M1.2.7: ESC closes modal
|
||||
- EC-M1.2.8: backdrop click closes modal
|
||||
|
||||
### AC-M1.3 — `/admin/orders/[id]` detail + edit
|
||||
- **Then** shows customer, stop, items, totals, payment section, edit form
|
||||
|
||||
EC-M1.3:
|
||||
- EC-M1.3.1: invalid id → "Order not found"
|
||||
- EC-M1.3.2: empty customer name on save → "Customer name is required"
|
||||
- EC-M1.3.3: negative discount → "Discount cannot be negative"
|
||||
- EC-M1.3.4: remove item → marked removed, deleted on save
|
||||
- EC-M1.3.5: save success → toast + page refreshes
|
||||
- EC-M1.3.6: status set to fulfilled (not exposed in UI) → should not be reachable
|
||||
- EC-M1.3.7: pickup toggle: not picked up → "Mark Picked Up"; picked up → button hidden
|
||||
- EC-M1.3.8: refund amount = 0 → button disabled
|
||||
- EC-M1.3.9: refund amount > remaining balance → validation error
|
||||
- EC-M1.3.10: refund amount = remaining balance → success
|
||||
|
||||
### AC-M1.4 — `/admin/v2/orders` mobile list
|
||||
- **Then** cards sorted; status filter via `?status=`
|
||||
|
||||
EC-M1.4:
|
||||
- EC-M1.4.1: 0 orders → "No orders yet"
|
||||
- EC-M1.4.2: status=placed → only placed orders shown
|
||||
- EC-M1.4.3: card click → /admin/v2/orders/[id]
|
||||
|
||||
### AC-M1.5 — `/admin/v2/orders/[id]` mobile detail
|
||||
- **Then** shows timeline + sticky action bar with status buttons
|
||||
|
||||
EC-M1.5:
|
||||
- EC-M1.5.1: status transitions: placed → ready → picked-up; cancel from any state
|
||||
- EC-M1.5.2: invalid id → notFound()
|
||||
|
||||
---
|
||||
|
||||
## STOPS (H)
|
||||
|
||||
### AC-M2.1 — `/admin/stops` list
|
||||
- **Then** table sorted by date asc; tabs filter by status
|
||||
|
||||
EC-M2.1:
|
||||
- EC-M2.1.1: search matches city/location substring
|
||||
- EC-M2.1.2: Add Stop modal opens
|
||||
- EC-M2.1.3: Upload Schedule modal opens
|
||||
- EC-M2.1.4: Edit → /admin/stops/[id]
|
||||
- EC-M2.1.5: Publish (draft only) → status changes to active, refreshes
|
||||
- EC-M2.1.6: Duplicate → /admin/stops/new?duplicate={id}
|
||||
- EC-M2.1.7: Delete → confirm popover → confirm → row removed
|
||||
|
||||
### AC-M2.2 — `/admin/stops/new` form
|
||||
- **Then** required: city/state/location/date/time/brand
|
||||
|
||||
EC-M2.2:
|
||||
- EC-M2.2.1: missing required field → red border + helper
|
||||
- EC-M2.2.2: `?duplicate={id}` pre-fills from existing stop
|
||||
- EC-M2.2.3: brand selector hidden for non-platform_admin
|
||||
|
||||
### AC-M2.3 — AddStopModal
|
||||
- **Then** opens with autofocus on city
|
||||
|
||||
EC-M2.3:
|
||||
- EC-M2.3.1: missing city/state/location/date → submission blocked
|
||||
- EC-M2.3.2: state uppercased on input
|
||||
- EC-M2.3.3: state max 2 chars
|
||||
- EC-M2.3.4: ESC closes
|
||||
- EC-M2.3.5: success → modal closes + list refreshes
|
||||
|
||||
### AC-M2.4 — LocationsTab
|
||||
- **Then** table with search + status tabs + pagination
|
||||
|
||||
EC-M2.4:
|
||||
- EC-M2.4.1: Add Venue modal opens
|
||||
- EC-M2.4.2: Edit modal opens with pre-filled fields
|
||||
- EC-M2.4.3: Delete with confirm → row removed
|
||||
- EC-M2.4.4: search matches name/address/city/contact_name
|
||||
|
||||
### AC-M2.5 — `/admin/v2/stops` mobile
|
||||
- **Then** cards bucketed by Morning/Afternoon/Evening/Anytime
|
||||
|
||||
EC-M2.5:
|
||||
- EC-M2.5.1: `?date=YYYY-MM-DD` filters
|
||||
- EC-M2.5.2: invalid date → empty or error
|
||||
- EC-M2.5.3: 0 stops → "No stops scheduled"
|
||||
- EC-M2.5.4: card with address → maps link works
|
||||
|
||||
---
|
||||
|
||||
## PRODUCTS (H)
|
||||
|
||||
### AC-M3.1 — `/admin/products` legacy list
|
||||
- **Then** shows all brand-scoped products
|
||||
|
||||
EC-M3.1:
|
||||
- EC-M3.1.1: 0 products → empty state
|
||||
- EC-M3.1.2: platform_admin sees products from all brands
|
||||
- EC-M3.1.3: brand_admin sees only their brand
|
||||
|
||||
### AC-M3.2 — `/admin/products/new` form
|
||||
- **Then** name required, type/brand/taxable/pickup_type/active selects
|
||||
|
||||
EC-M3.2:
|
||||
- EC-M3.2.1: missing name → blocked
|
||||
- EC-M3.2.2: image upload: drag-drop or click → preview
|
||||
- EC-M3.2.3: oversized image (>5MB) → error
|
||||
- EC-M3.2.4: wrong type → error
|
||||
- EC-M3.2.5: success → /admin/products
|
||||
- EC-M3.2.6: brand locked for non-platform_admin
|
||||
|
||||
### AC-M3.3 — `/admin/products/import` CSV
|
||||
- **Then** preview table shows parsed rows
|
||||
|
||||
EC-M3.3:
|
||||
- EC-M3.3.1: malformed CSV → parse errors listed
|
||||
- EC-M3.3.2: missing brand ID → Import disabled
|
||||
- EC-M3.3.3: success → counts panel
|
||||
|
||||
### AC-M3.4 — `/admin/v2/products` mobile
|
||||
- **Then** cards with status pill + StockAdjustButton
|
||||
|
||||
EC-M3.4:
|
||||
- EC-M3.4.1: `?filter=in-stock` → only products with inventory > 0
|
||||
- EC-M3.4.2: `?filter=out` → only inventory = 0
|
||||
- EC-M3.4.3: `?filter=hidden` → only active=false
|
||||
- EC-M3.4.4: StockAdjustButton increments/decrements inventory
|
||||
|
||||
---
|
||||
|
||||
## COMMUNICATIONS (H)
|
||||
|
||||
### AC-M6.1 — Campaigns list + create + edit
|
||||
- **Then** list with type/status filters
|
||||
|
||||
EC-M6.1:
|
||||
- EC-M6.1.1: New Campaign → name + type required → Create
|
||||
- EC-M6.1.2: Save Draft → status=draft, no send
|
||||
- EC-M6.1.3: Schedule Campaign with future datetime → status=scheduled
|
||||
- EC-M6.1.4: Send Campaign → calls sendCampaign → status=sent
|
||||
- EC-M6.1.5: Schedule with past datetime → blocked (datetime-local min = now)
|
||||
- EC-M6.1.6: missing subject/body → blocked
|
||||
- EC-M6.1.7: previewAudience → shows count + sample emails
|
||||
|
||||
### AC-M6.2 — Templates list + edit
|
||||
- **Then** edit fields: subject, body_text, body_html, template_type, campaign_type
|
||||
|
||||
EC-M6.2:
|
||||
- EC-M6.2.1: missing subject → blocked (subject is NOT NULL)
|
||||
- EC-M6.2.2: success → list refreshes
|
||||
|
||||
### AC-M6.3 — Contacts list
|
||||
- **Then** search + source filter + pagination
|
||||
|
||||
EC-M6.3:
|
||||
- EC-M6.3.1: delete with confirm → row removed
|
||||
- EC-M6.3.2: Export CSV → file downloads
|
||||
- EC-M6.3.3: pagination prev/next
|
||||
|
||||
### AC-M6.4 — Message logs
|
||||
- **Then** status filter + search + pagination (1-5 pages)
|
||||
|
||||
EC-M6.4:
|
||||
- EC-M6.4.1: filter by status=failed → only failed shown
|
||||
- EC-M6.4.2: stats cards reflect filtered count
|
||||
|
||||
### AC-M6.5 — Abandoned Carts
|
||||
- **Then** stats + filter (all/active/recovered) + pagination
|
||||
|
||||
EC-M6.5:
|
||||
- EC-M6.5.1: View → detail modal with items + timestamps
|
||||
- EC-M6.5.2: Close → status=manually_closed
|
||||
- EC-M6.5.3: Resend → calls resendAbandonedCartEmail
|
||||
|
||||
### AC-M6.6 — Communication Settings
|
||||
- **Then** senderEmail/senderName/replyTo/footerHtml form
|
||||
|
||||
EC-M6.6:
|
||||
- EC-M6.6.1: invalid email → error
|
||||
- EC-M6.6.2: `{unsubscribe_url}` placeholder preserved in footerHtml
|
||||
|
||||
### AC-M6.7 — ContactImportForm (multi-step)
|
||||
- **Then** drag/drop or click → preview → import
|
||||
|
||||
EC-M6.7:
|
||||
- EC-M6.7.1: file >5MB → bucket path
|
||||
- EC-M6.7.2: missing required mapping → blocked
|
||||
- EC-M6.7.3: success → counts panel
|
||||
|
||||
---
|
||||
|
||||
## WHOLESALE (H)
|
||||
|
||||
### AC-M7.1 — Wholesale dashboard loads
|
||||
- **Then** shows tabs for Customers / Orders / Pricing / Settings
|
||||
|
||||
EC-M7.1:
|
||||
- EC-M7.1.1: 0 customers → empty state
|
||||
- EC-M7.1.2: 0 orders → empty state
|
||||
|
||||
### AC-M7.2 — Wholesale Customers
|
||||
- **Then** list with search + status filter
|
||||
|
||||
EC-M7.2:
|
||||
- EC-M7.2.1: bulk "Send Price Sheet" → calls sendPriceSheetToCustomer N times
|
||||
|
||||
### AC-M7.3 — Wholesale Orders
|
||||
- **Then** list with status filter
|
||||
|
||||
EC-M7.3:
|
||||
- EC-M7.3.1: bulk Fulfill → multiple fulfillments
|
||||
- EC-M7.3.2: bulk Deposit → multiple deposits
|
||||
|
||||
### AC-M7.4 — Wholesale Settings
|
||||
- **Then** form saves via updateWholesaleSettings
|
||||
|
||||
EC-M7.4:
|
||||
- EC-M7.4.1: invalid email → error
|
||||
- EC-M7.4.2: min_order_amount < 0 → validation
|
||||
|
||||
---
|
||||
|
||||
## SETTINGS (H)
|
||||
|
||||
### AC-M8.1 — BrandSettingsForm
|
||||
- **Then** all sections save atomically
|
||||
|
||||
EC-M8.1:
|
||||
- EC-M8.1.1: logo upload → preview + URL stored
|
||||
- EC-M8.1.2: nexus state pill: add via Enter or comma → chip appears
|
||||
- EC-M8.1.3: nexus state pill: click ✕ → chip removed
|
||||
- EC-M8.1.4: state field: lowercased input → uppercased display
|
||||
- EC-M8.1.5: brand colors: text input + color picker stay in sync
|
||||
- EC-M8.1.6: feature toggles: each toggle saves independently
|
||||
|
||||
### AC-M8.2 — BrandFeatureCards
|
||||
- **Then** each card shows status + Enable/Disable
|
||||
|
||||
EC-M8.2:
|
||||
- EC-M8.2.1: Enable → confirmation modal → success toast + card shows Active
|
||||
- EC-M8.2.2: Disable → optimistic toggle → rollback on failure
|
||||
- EC-M8.2.3: "Open →" link visible only when enabled + adminRoute set
|
||||
|
||||
### AC-M8.3 — CreateUserModal
|
||||
- **Then** email/password (≥6) required
|
||||
|
||||
EC-M8.3:
|
||||
- EC-M8.3.1: invalid email (no @) → blocked
|
||||
- EC-M8.3.2: password < 6 → blocked
|
||||
- EC-M8.3.3: missing brand for brand_admin → blocked
|
||||
- EC-M8.3.4: success → temp password surfaced + email-sent status
|
||||
- EC-M8.3.5: Copy button copies temp password
|
||||
- EC-M8.3.6: role options depend on caller role
|
||||
|
||||
### AC-M8.4 — PaymentSettingsForm
|
||||
- **Then** provider radio + Stripe/Square OAuth buttons + Square Location ID
|
||||
|
||||
EC-M8.4:
|
||||
- EC-M8.4.1: Square Location ID without `L` prefix → blocked
|
||||
- EC-M8.4.2: Save disabled when not dirty
|
||||
- EC-M8.4.3: Stripe OAuth flow → return with ?stripe_connected=true → URL param stripped
|
||||
- EC-M8.4.4: Square OAuth flow → return with ?square_connected=true → URL param stripped
|
||||
- EC-M8.4.5: Sync Products/Orders/All Now → shows result banner
|
||||
|
||||
### AC-M8.5 — AdvancedSettingsClient tabs
|
||||
- **Then** tabs render Integrations / AI / Square / Shipping / Webhooks / Payments
|
||||
|
||||
EC-M8.5:
|
||||
- EC-M8.5.1: Integrations: Resend + Twilio save independently
|
||||
- EC-M8.5.2: AI Tools: provider selection changes default model
|
||||
- EC-M8.5.3: AI Tools: API key required for Test
|
||||
- EC-M8.5.4: Square Sync: App ID + Access Token required for Test
|
||||
- EC-M8.5.5: Shipping: Test Connection (simulated) shows success after 1s
|
||||
- EC-M8.5.6: Webhooks tab shows "Coming Soon"
|
||||
- EC-M8.5.7: Payments: Connect with Stripe → redirects to Stripe
|
||||
|
||||
---
|
||||
|
||||
## ANALYTICS (M)
|
||||
|
||||
### AC-M9.1 — AnalyticsDashboard loads
|
||||
- **Then** KPI cards + revenue chart + top products + recent orders + customer growth + funnel
|
||||
|
||||
EC-M9.1:
|
||||
- EC-M9.1.1: period selector change → chart re-renders (visual only, no refetch)
|
||||
- EC-M9.1.2: Refresh → fetches all
|
||||
- EC-M9.1.3: Export Report → no-op
|
||||
- EC-M9.1.4: 0 revenue → "No revenue data available yet"
|
||||
- EC-M9.1.5: error → retry button works
|
||||
|
||||
---
|
||||
|
||||
## TIME TRACKING (H)
|
||||
|
||||
### AC-M11.1 — TimeTrackingAdminPanel tabs
|
||||
- **Then** Summary / Workers / Tasks / Logs / Settings
|
||||
|
||||
EC-M11.1:
|
||||
- EC-M11.1.1: 0 workers → empty
|
||||
- EC-M11.1.2: Add Worker → name/role/language/active → Create
|
||||
- EC-M11.1.3: Reset PIN → calls resetTimeWorkerPin
|
||||
- EC-M11.1.4: Delete Worker → confirm → removed
|
||||
- EC-M11.1.5: Logs filter by worker + task + date range → list updates
|
||||
- EC-M11.1.6: Logs pagination 50/page
|
||||
- EC-M11.1.7: Export → CSV downloads from /api/time-tracking/export
|
||||
|
||||
---
|
||||
|
||||
## WATER LOG (H)
|
||||
|
||||
### AC-M11.2 — WaterLogAdminPanel
|
||||
- **Then** Add Headgate inline + Add User inline + filter chips + Export CSV
|
||||
|
||||
EC-M11.2:
|
||||
- EC-M11.2.1: missing name → blocked
|
||||
- EC-M11.2.2: PIN auto-generated on user create → PinBanner shows
|
||||
- EC-M11.2.3: Rotate token → token changes
|
||||
- EC-M11.2.4: Filter chips → entries filtered
|
||||
- EC-M11.2.5: Export CSV → downloads
|
||||
- EC-M11.2.6: Preview Report → window.alert
|
||||
|
||||
### AC-M11.3 — HeadgatesManager bulk QR
|
||||
- **Then** select multiple + Print → new window with QR sheet
|
||||
|
||||
EC-M11.3:
|
||||
- EC-M11.3.1: 0 selected → Print disabled
|
||||
- EC-M11.3.2: regenerate token → existing printed QRs invalidated
|
||||
- EC-M11.3.3: per-headgate QR Download PNG
|
||||
|
||||
### AC-M11.4 — Water Admin Settings
|
||||
- **Then** toggles + session duration slider + alert phone
|
||||
|
||||
EC-M11.4:
|
||||
- EC-M11.4.1: Regenerate PIN → confirm → PIN revealed
|
||||
- EC-M11.4.2: session duration 1-168 hours only
|
||||
- EC-M11.4.3: Save Settings → success banner
|
||||
|
||||
---
|
||||
|
||||
## ROUTE TRACE (M)
|
||||
|
||||
### AC-M11.5 — RouteTracePage loads
|
||||
- **Then** shell renders with stat cards + lot list
|
||||
|
||||
EC-M11.5:
|
||||
- EC-M11.5.1: missing feature flag → redirect
|
||||
- EC-M11.5.2: lot detail → timeline + orders
|
||||
|
||||
---
|
||||
|
||||
## LAUNCH CHECKLIST (L — known cosmetic)
|
||||
|
||||
### AC-M11.6 — LaunchChecklist renders
|
||||
- **Then** 8 categories × 4 tasks
|
||||
|
||||
EC-M11.6:
|
||||
- EC-M11.6.1: "Mark All Complete" → no-op (documented limitation)
|
||||
- EC-M11.6.2: "Export PDF" → no-op
|
||||
- EC-M11.6.3: Per-task checkbox → no-op
|
||||
- EC-M11.6.4: "Go →" links work
|
||||
|
||||
---
|
||||
|
||||
## IMPORT CENTER (M)
|
||||
|
||||
### AC-M11.7 — ImportCenterClient wizard
|
||||
- **Then** Upload → Analyze → Preview → Import
|
||||
|
||||
EC-M11.7:
|
||||
- EC-M11.7.1: drag/drop or click upload
|
||||
- EC-M11.7.2: file >10MB → blocked
|
||||
- EC-M11.7.3: AI detection confidence <80% → type override buttons shown
|
||||
- EC-M11.7.4: column mapping dropdowns
|
||||
- EC-M11.7.5: Import disabled when type=unknown
|
||||
- EC-M11.7.6: success → counts + "View" link
|
||||
|
||||
---
|
||||
|
||||
## PICKUP (C)
|
||||
|
||||
### AC-M4.1 — DriverPickupPanel
|
||||
- **Then** pending orders + picked-up orders collapsible
|
||||
|
||||
EC-M4.1:
|
||||
- EC-M4.1.1: Pick Up → calls markPickupComplete → toast 3s
|
||||
- EC-M4.1.2: stop filter → list filtered
|
||||
- EC-M4.1.3: search → matches name/phone/order id prefix
|
||||
- EC-M4.1.4: 0 pending → empty state
|
||||
- EC-M4.1.5: picked-up older than 72h → hidden
|
||||
|
||||
---
|
||||
|
||||
## SHIPPING (C)
|
||||
|
||||
### AC-M5.1 — ShippingFulfillmentPanel
|
||||
- **Then** shipping orders list
|
||||
|
||||
EC-M5.1:
|
||||
- EC-M5.1.1: 0 shipping orders → empty
|
||||
- EC-M5.1.2: filter by status
|
||||
|
||||
---
|
||||
|
||||
## PUBLIC/AUTH (H)
|
||||
|
||||
### AC-P1 — Login page
|
||||
- **Then** email + password submit to /api/auth/sign-in
|
||||
|
||||
EC-P1:
|
||||
- EC-P1.1: missing email → HTML5 validation blocks submit
|
||||
- EC-P1.2: missing password → blocked
|
||||
- EC-P1.3: invalid credentials → error banner
|
||||
- EC-P1.4: dev_session=platform_admin → sets cookie and redirects /admin
|
||||
- EC-P1.5: Google button disabled while loading
|
||||
- EC-P1.6: Forgot? link → /forgot-password (or /api/forgot-password?)
|
||||
|
||||
### AC-P2 — Change password
|
||||
- **Then** new password + confirm match + ≥8 chars
|
||||
|
||||
EC-P2:
|
||||
- EC-P2.1: passwords don't match → error
|
||||
- EC-P2.2: <8 chars → error
|
||||
- EC-P2.3: success → /admin
|
||||
- EC-P2.4: "Sign out instead" → /logout
|
||||
|
||||
### AC-P3 — Reset password
|
||||
- **Then** new password + confirm
|
||||
|
||||
EC-P3:
|
||||
- EC-P3.1: same as AC-P2
|
||||
|
||||
### AC-P4 — Logout
|
||||
- **Then** spinner → /api/auth/sign-out → /login
|
||||
|
||||
### AC-P5 — Maintenance splash
|
||||
- **Then** no controls, only mailto + /contact links
|
||||
|
||||
### AC-P6 — Homepage
|
||||
- **Then** hero + CTA links
|
||||
|
||||
### AC-P7 — Pricing
|
||||
- **Then** BillingToggle, FAQ accordion, Compare table expand
|
||||
|
||||
EC-P7:
|
||||
- EC-P7.1: Monthly ↔ Annual swap content
|
||||
- EC-P7.2: FAQ accordion: each opens/closes independently
|
||||
- EC-P7.3: Compare table expand/collapse
|
||||
|
||||
### AC-P8 — Contact
|
||||
- **Then** form simulated submit (setTimeout)
|
||||
|
||||
EC-P8:
|
||||
- EC-P8.1: missing required field → blocked
|
||||
- EC-P8.2: success card replaces form
|
||||
|
||||
### AC-P9 — Blog
|
||||
- **Then** static + decorative newsletter form
|
||||
|
||||
### AC-P10 — Changelog
|
||||
- **Then** static timeline + RSS link
|
||||
|
||||
### AC-P11 — Roadmap
|
||||
- **Then** 3-column + Suggestion form (no submit) + Upvote buttons (no handlers)
|
||||
|
||||
EC-P11:
|
||||
- EC-P11.1: form submit does nothing (reloads page)
|
||||
- EC-P11.2: upvote buttons no-op
|
||||
|
||||
### AC-P12 — Security
|
||||
- **Then** static + mailto
|
||||
|
||||
### AC-P13 — Privacy / Terms
|
||||
- **Then** static text
|
||||
|
||||
### AC-P14 — Brands showcase
|
||||
- **Then** per-brand visit links
|
||||
|
||||
### AC-P15 — Waitlist
|
||||
- **Then** form posts to /api/waitlist
|
||||
|
||||
EC-P15:
|
||||
- EC-P15.1: missing email → blocked
|
||||
- EC-P15.2: success card replaces form
|
||||
- EC-P15.3: server error → inline error
|
||||
|
||||
### AC-P16 — Cart
|
||||
- **Then** items list + qty controls + stop picker
|
||||
|
||||
EC-P16:
|
||||
- EC-P16.1: − button decreases qty; at qty=1, "Remove" is the only path
|
||||
- EC-P16.2: + increases qty
|
||||
- EC-P16.3: Remove → item disappears
|
||||
- EC-P16.4: stop mismatch → "Choose Correct Stop" alert
|
||||
- EC-P16.5: incompatible items → "Remove Incompatible Items First" CTA
|
||||
- EC-P16.6: empty cart → "Cart is Empty" CTA
|
||||
- EC-P16.7: availability error → "Remove Incompatible Items First"
|
||||
- EC-P16.8: ESC closes stop picker
|
||||
- EC-P16.9: backdrop closes stop picker
|
||||
|
||||
### AC-P17 — Checkout
|
||||
- **Then** customer details + stop select + shipping (if ship) + Stripe Express iframe
|
||||
|
||||
EC-P17:
|
||||
- EC-P17.1: missing email → blocked
|
||||
- EC-P17.2: no stop + ship items → blocked (?)
|
||||
- EC-P17.3: state field uppercased
|
||||
- EC-P17.4: "Use secure hosted checkout" → Stripe redirect (uses placeholder key, will fail in QA)
|
||||
- EC-P17.5: empty cart → "Back to storefront" link
|
||||
|
||||
### AC-P18 — Auth API routes
|
||||
- /api/auth/sign-in: 400/401/500 surfaces error
|
||||
- /api/auth/sign-out: redirect /login
|
||||
- /api/auth/forgot-password: always success (anti-enumeration)
|
||||
- /api/auth/reset-password: 400 on <8 chars
|
||||
- /api/auth/change-password: auth required
|
||||
|
||||
---
|
||||
|
||||
## Known cosmetic-only behaviors (NOT bugs)
|
||||
|
||||
- `/admin/launch-checklist` checkboxes, "Mark All Complete", "Export PDF" are all no-ops (M11.6)
|
||||
- `/admin/analytics` "Export Report" is no-op (M9.1 EC-3)
|
||||
- `/admin/analytics` period selector doesn't refetch (M9.1 EC-1)
|
||||
- `/admin/me` profile + email change forms always show "temporarily unavailable" (AdminMeClient)
|
||||
- `/admin/advanced/shipping` and `/admin/settings/square-sync/advanced` are static mocks with setTimeout simulators
|
||||
- `/admin/communications/compose`, `/contacts`, `/segments`, `/logs`, `/analytics` all redirect to `?tab=` query (preserved backward compat)
|
||||
- `/blog/newsletter` and `/blog/download` buttons have no handlers
|
||||
- `/roadmap/suggestion` form and upvotes have no handlers
|
||||
- `/admin/orders/[id]` does NOT expose "fulfilled" status (deliberate)
|
||||
|
||||
These are **documented limitations**, not bugs. Any change to them should be reviewed for product intent first.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Environment Differences from Production
|
||||
|
||||
This audit runs against a local Dockerized Postgres + dev server, not production. Below are the differences an auditor should know about before trusting any finding.
|
||||
|
||||
## 1. Neon Auth is stubbed
|
||||
|
||||
Production uses Neon Auth (Better Auth) to manage `neon_auth.user`. In this audit, that schema/table is a minimal local stub created by `db/migrations/0000_qa_neon_auth_stub.sql`:
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS neon_auth;
|
||||
CREATE TABLE IF NOT EXISTS neon_auth.user (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
Authentication is **not exercised via the real sign-in flow**. Instead, the `dev_session` cookie impersonates roles:
|
||||
|
||||
| Cookie value | Impersonates |
|
||||
|---|---|
|
||||
| `dev_session=platform_admin` | Cross-brand admin (`role='platform_admin'`, `brand_id=null`) |
|
||||
| `dev_session=brand_admin` | Single-brand admin |
|
||||
| `dev_session=store_employee` | Limited-scope employee |
|
||||
|
||||
This is gated by `process.env.NODE_ENV !== "production"`. Any bug found in the real Neon Auth flow (sign-in, password reset, MFA, OAuth) **cannot** be reproduced here.
|
||||
|
||||
## 2. Migration 0002 (`0002_admin_password.sql`) is marked applied but does not run
|
||||
|
||||
The migration references a legacy `users` table that no longer exists in the current schema (the codebase migrated from Auth.js Credentials to Neon Auth). The migration is recorded in `_migrations` so the runner skips it, and the column it would have added (`users.password_hash`) is absent. **This is a separate latent issue** worth a follow-up PR: 0002 should be removed from the migrations directory or rewritten as a no-op.
|
||||
|
||||
## 3. New migration `0000_qa_neon_auth_stub.sql` (audit-only)
|
||||
|
||||
Added to make migrations runnable without Neon Auth. Should not be applied to production (it would conflict with the real `neon_auth.user` table). Move to a dev-only seed if reused.
|
||||
|
||||
## 4. Seed file `db/seeds/2026-qa-audit-scale.sql` (audit-only)
|
||||
|
||||
Replaces the broken `db/seed.ts`, which references removed columns (`brand_settings.brand_name`). Idempotent on a clean DB; re-runs without reset will duplicate rows in tables lacking unique constraints on natural keys (orders, order_items).
|
||||
|
||||
## 5. Stripe / Resend / Square credentials are placeholders
|
||||
|
||||
```
|
||||
STRIPE_SECRET_KEY=sk_test_placeholder_for_qa_audit
|
||||
RESEND_API_KEY=re_placeholder_for_qa_audit
|
||||
SQUARE_ACCESS_TOKEN=square_placeholder_for_qa_audit
|
||||
```
|
||||
|
||||
Real network calls to those services would fail. The audit tests UI navigation, server actions that don't depend on real third-party responses, and DB-backed flows. Stripe checkout, real Resend sends, Square inventory sync — these are out of scope for this audit run.
|
||||
|
||||
## 6. Database port
|
||||
|
||||
The audit Postgres runs on `:5433`, not `:5432`. `:5432` is occupied by `n8n-postgres-1`.
|
||||
|
||||
## 7. The "production-scale" data is sanitized
|
||||
|
||||
No real customer PII, no real payment tokens, no real email addresses. All customer emails are `@routecomm.example` (RFC 2606 reserved). Phone numbers follow the +1-555-01xx-xxxx range reserved for fictional use.
|
||||
|
||||
## 8. RBAC scope
|
||||
|
||||
This audit tests **only** the `platform_admin` role. The QA users `qa-tuxedo@routecomm.example` and `qa-ird@routecomm.example` are seeded for follow-up brand-scoped testing but not exercised here.
|
||||
@@ -0,0 +1,333 @@
|
||||
# QA Audit Inventory — 2026-06-25
|
||||
|
||||
> Generated for [Loop 010 — full product evaluation](https://signals.forwardfuture.com/loop-library/loops/full-product-evaluation-loop/).
|
||||
> Scope: Admin shell (all 19 modules) + public/auth pages, tested as `platform_admin` via `dev_session` cookie.
|
||||
> Source: 5 parallel `explore` subagents enumerated every UI control, server action, state, and workflow.
|
||||
|
||||
## Inventory organization
|
||||
|
||||
- `R1`–`R4`: Role/identity assumptions
|
||||
- `A1`–`A11`: Admin shell, layout, dashboard
|
||||
- `M1`–`M11`: Admin modules (orders, stops, products, pickup, shipping, communications, wholesale, settings, analytics, users, import)
|
||||
- `P1`–`P19`: Public + auth pages
|
||||
- `T1`–`T6`: Cross-cutting patterns, design system, server-action catalog
|
||||
|
||||
Each item: route → purpose → buttons → forms → modals → states → workflows → server actions.
|
||||
|
||||
---
|
||||
|
||||
## R — Roles & identity
|
||||
|
||||
### R1 — `dev_session` cookie impersonation
|
||||
**File:** `src/lib/admin-permissions.ts` lines 36-39.
|
||||
Gated by `NODE_ENV !== "production"`. Values: `platform_admin`, `brand_admin`, `store_employee`. Returns admin user via `buildDevAdmin(role)` bypassing Neon Auth.
|
||||
|
||||
### R2 — Real Neon Auth (Better Auth)
|
||||
**File:** `src/lib/auth.ts`. Email/password sign-in, password reset, OAuth via `signInWithGoogleAction`. Session via `getSession()`. **Not exercised** in this audit (auth is stubbed).
|
||||
|
||||
### R3 — RBAC permission flags
|
||||
**File:** `admin_users` table; flags: `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`, `can_manage_pickup`, `can_manage_messages`, `can_manage_refunds`, `can_manage_users`, `active`. Default brand_admin = orders/products/stops/customers/pickup/messages/reports = `true`, rest `false`.
|
||||
|
||||
### R4 — Tenant scoping (brand isolation)
|
||||
- App-layer: `effectiveBrandId = brandId ?? adminUser.brand_id ?? null`; threaded through page → client.
|
||||
- DB: `current_brand_id()` GUC + RLS policies on most tables (`brand_id = current_brand_id() OR is_platform_admin()`).
|
||||
- Platform admin: `brand_id IS NULL`, sees all brands via `withPlatformAdmin()`.
|
||||
|
||||
---
|
||||
|
||||
## A — Admin shell, layout, dashboard
|
||||
|
||||
### A1 — `/admin/layout.tsx`
|
||||
Server root layout. Gates access via `getAdminUser()`, redirects on `must_change_password`. Mounts: `AdminSidebar` (desktop), `MobileTabBar` (mobile via v2 layout), `CommandPalette`, `PWAInstallPrompt`, `ToastContainer`, `RouteAnnouncer`, `SmoothViewTransition`. Brand/brand-list/addons fetched with empty fallbacks on error.
|
||||
|
||||
### A2 — `/admin/page.tsx` (legacy dashboard)
|
||||
Redirects to `/admin/v2`. Two paths: `role === "store_employee"` → 2-card landing (Pickup Lookup + Wholesale); otherwise fetches `getDashboardStats()` + `getBillingOverview()` server-side, renders `DashboardClient`.
|
||||
|
||||
### A3 — `/admin/v2/layout.tsx` + `/admin/v2/page.tsx` (mobile-first dashboard)
|
||||
Mobile "Today" dashboard. Fetches summary/stops/orders in parallel. `Promise.all` failure → silent empty state. Renders `EmptyState` ("No brand selected" / "Nothing to show yet"). `pending_fulfillment > 0` → amber callout link `/admin/v2/orders?status=placed`. Top 5 today stops + top 5 pending orders. Pull-to-refresh wrapper.
|
||||
|
||||
### A4 — `AdminShell.tsx`
|
||||
Responsive shell: `useMediaQuery("(min-width: 1024px)")` swaps desktop sidebar vs mobile (`OfflineBanner` + `MobileTabBar`). Mounts `OfflineBanner` only on mobile. `pb-20` mobile bottom padding for tab bar.
|
||||
|
||||
### A5 — `AdminSidebar.tsx`
|
||||
Desktop nav + mobile slide-in. Nav groups: Workspace / Operations / Communications / Growth / Tracking / Insights / Settings. Brand selector pill. Sign-out button (`signOutAction`). Hamburger on `<lg`. Focus trap + arrow-key navigation + body scroll lock + ESC closes. `requiresPlatformAdmin` filter; `enabledAddons` hides Water Log/Route Trace when disabled.
|
||||
|
||||
### A6 — `MobileTabBar.tsx`
|
||||
Bottom nav for `/admin/v2/*`. 5 tabs: Home / Orders / Stops / Products / More (opens `MoreSheet`). Active highlight via `pathname` match.
|
||||
|
||||
### A7 — `MoreSheet.tsx`
|
||||
Right-anchored native `<dialog>`. Sections: Operations (Pickup, Shipping, Reports, Analytics), Marketing (Communications, Sales), Settings (Settings, Advanced). Saves focus on open, restores on close.
|
||||
|
||||
### A8 — `CommandPalette.tsx`
|
||||
Cmd/Ctrl+K modal. Fuzzy search over static entry list (`./command-palette-data.ts`). Up to 8 results. ↑↓ to navigate, Enter to select, ESC closes. `body.style.overflow = "hidden"` while open. Honors `prefers-reduced-motion`.
|
||||
|
||||
### A9 — `BrandSelector.tsx`
|
||||
Pill dropdown for active brand. Calls `setActiveBrand(brandId)` server action, then `router.refresh()`. Outside-click + ESC close. "All brands" option for platform_admin only. Multi-brand badge.
|
||||
|
||||
### A10 — `AdminAccessDenied.tsx`
|
||||
Static card with "Go to Login" + "Return to homepage" links.
|
||||
|
||||
### A11 — `OfflineBanner.tsx`
|
||||
Sticky top banner: warning-soft when syncing, danger-soft when offline. Polls pendingCount every 1 s. Hidden pre-mount to avoid hydration mismatch.
|
||||
|
||||
---
|
||||
|
||||
## M — Admin modules
|
||||
|
||||
### M1 — Orders
|
||||
- `/admin/orders` (legacy) → `AdminOrdersPanel`: KPI tiles, status tabs (all/pending/picked_up), search (name/phone/id), stop multi-filter dropdown, table with checkbox + bulk pickup, pagination (PAGE_SIZE 20), `+ New Order` modal. Modal fields: customer name (required), email (optional), phone (optional), stop (optional), items[product+qty+price+fulfillment]. Workflows: create→full-page-reload, single pickup (optimistic), bulk pickup (loop).
|
||||
- `/admin/orders/[id]` → `OrderEditForm` + `OrderPaymentSection` + `OrderPickupAction`. Edit fields: items qty/price, discount (must be ≥ 0), customer name (required), email/phone, status (pending/confirmed/cancelled — **fulfilled not exposed**), pickup toggle, internal notes. Payment fields: processor (none/manual/stripe/square/cash/venmo/other), payment status, transaction id, refund amount (0 < x ≤ remaining balance) + reason.
|
||||
- `/admin/orders/new` → redirect to `/admin/orders?new=true` (opens modal).
|
||||
- `/admin/v2/orders` → card-style list, `?status=` filter (placed/ready/picked-up/cancelled), LIMIT 50, no pagination.
|
||||
- `/admin/v2/orders/[id]` → mobile detail with `FulfillmentTimeline` + sticky action bar (`OrderActionButtons`).
|
||||
|
||||
### M2 — Stops
|
||||
- `/admin/stops` → `AdminStopsPanel`: status tabs (all/active/draft/inactive), search city/location, `Add Stop` modal, `Upload Schedule` modal, per-row Edit/Publish/Duplicate/Delete. Delete uses inline popover confirm. PAGE_SIZE 50.
|
||||
- `/admin/stops/[id]` → product assignment + edit form + `MessageCustomersSection`.
|
||||
- `/admin/stops/new` → inline `NewStopForm` (not modal). Fields: city/state/location/date/time (required), brand select, active, address/zip/cutoff (optional). Also supports `?duplicate={id}`.
|
||||
- `/admin/v2/stops` → card list bucketed by Morning/Afternoon/Evening/Anytime, `?date=` filter, LIMIT 200.
|
||||
- Modals: `AddStopModal`, `EditStopModal`, `AddLocationModal`, `EditLocationModal`, `LocationsTab`.
|
||||
|
||||
### M3 — Products
|
||||
- `/admin/products` → `ProductsClient` (legacy): list with brand scoping (platform_admin sees all). No client pagination.
|
||||
- `/admin/products/new` → `NewProductForm`: name (required), description, price, type (pickup/shipping/both), brand (locked for non-platform_admin), taxable, pickup_type, active, image upload (drag/drop, 5MB cap, client-side resize to 1200px).
|
||||
- `/admin/products/import` → CSV upload → `parseProductCSV` → preview → `importProductsBatch`.
|
||||
- `/admin/v2/products` → mobile card list with `?filter=` (all/in-stock/low/out/hidden) + `StockAdjustButton` per card.
|
||||
|
||||
### M4 — Pickup
|
||||
- `/admin/pickup` → `DriverPickupPanel`. Pending orders (filter by stop + search), per-order `Pick Up` button (calls `markPickupComplete`). Picked-up section collapsible (last 72h). Toast `✓ Order picked up` 3s.
|
||||
|
||||
### M5 — Shipping
|
||||
- `/admin/shipping` → `ShippingFulfillmentPanel`. Pre-loaded via `getShippingOrders()`. (Details in deeper inspection.)
|
||||
- `/admin/settings/shipping` (advanced subtab) → `AdvancedShipping`: static UI mock — no server action calls. Carrier select (fedex/ups/usps/dhl), account/api fields, eye toggle, **Test Connection is simulated (1s setTimeout), Save Settings is simulated (500ms setTimeout)**.
|
||||
|
||||
### M6 — Communications (Harvest Reach)
|
||||
Unified hub at `/admin/communications` with 8 tabs:
|
||||
- **Campaigns**: list with type/status filters. `New Campaign` modal (name + campaignType). Edit panel: name, type, template, audience target (stop/zip_code/customer_history/all_customers), stop_id/dates, subject, body, schedule (now/later + datetime-local). Save Draft / Schedule Campaign / Send Campaign buttons. `upsertCampaign`, `deleteCampaign`, `getCampaignTemplates`, `getCommunicationSegments`, `previewCampaignAudience`, `sendCampaign` server actions.
|
||||
- **Compose**: legacy redirect target.
|
||||
- **Templates**: list + edit (template_type, subject, body_text, body_html, campaign_type). Server: `getCommunicationTemplates`, `getTemplateById`.
|
||||
- **Contacts**: search + source filter, pagination (50/page, offset), per-row delete (confirm), export CSV (`exportContacts`). Bulk: none. Card layout on `<sm`.
|
||||
- **Segments**: redirect → `?tab=segments`.
|
||||
- **Logs**: `MessageLogPanel` — search by email/subject/status, status filter, pagination (20/page, numeric 1-5). Stats cards: total/delivered/failed/pending. `getMessageLogs` (server-side limit 100).
|
||||
- **Analytics**: redirect → `?tab=analytics`.
|
||||
- **Settings** (`/admin/communications/settings`): `CommunicationSettingsForm` — sender email/name, reply-to, footerHtml (supports `{unsubscribe_url}` placeholder).
|
||||
- **Abandoned Carts** (`/admin/communications/abandoned-carts`): 3-step sequence (1h/24h/48h). Stats (total/active/recovered/expired), filter, pagination (25/page, 7×7 prev/next), View/Close/Resend actions per cart. Modal: cart detail with items + timestamps.
|
||||
- **Welcome Sequence** (`/admin/communications/welcome-sequence`): 4-email dashboard.
|
||||
|
||||
Server actions: `getCommunicationCampaigns`, `getCommunicationTemplates`, `getCommunicationContacts`, `getMessageLogs`, `getCommunicationSettings`, `upsertCommunicationSettings`, `getAbandonedCarts`, `manuallyCloseAbandonedCart`, `resendAbandonedCartEmail`, `sendStopBlast`, `previewContactImport`, `importContactsBatch`, `uploadContactsToBucket`, `processBucketImport`, `listImportHistory`, `exportContacts`.
|
||||
|
||||
### M7 — Wholesale
|
||||
- `/admin/wholesale` → `WholesaleClient` (mounted by `getActiveBrandId`). Dashboard tabs: Customers / Orders / Pricing / Settings.
|
||||
- Customers: list with search, status filter (active/inactive/suspended/pending), pagination, bulk "Send Price Sheet" (counts).
|
||||
- Orders: list with status filter, bulk Fulfill / bulk Deposit (with confirms).
|
||||
- Pricing: per-customer price overrides.
|
||||
- Settings: `WholesaleSettingsForm` — require_approval, min_order_amount, online_payment_enabled, pickup_location, fob_location, from_email, invoice_business_name.
|
||||
|
||||
Server actions: `getWholesaleCustomers`, `getWholesaleOrders`, `updateWholesaleCustomer`, `sendPriceSheetToCustomer`, `bulkFulfillOrders`, `recordWholesaleDeposit`, `getWholesaleSettings`, `updateWholesaleSettings`.
|
||||
|
||||
### M8 — Settings (`/admin/settings`)
|
||||
Main hub. Tabs (URL anchor): brand / addons / users / billing.
|
||||
- **Brand** (`/admin/settings` `#brand`): `BrandSettingsForm` — company info, address, 4 logo uploads, email/invoice branding, storefront customization (tagline, hero image, about, footer), brand colors (4 pickers), tax settings (collect toggle + nexus states), feature toggles (wholesale/zip/schedule-pdf/text-alerts), schedule PDF footer.
|
||||
- **Add-ons** (`/admin/settings` `#addons`): `BrandFeatureCards` — grid of cards from `ADDON_CATALOG`. Enable/Disable toggle (with GlassModal confirm) + optimistic with rollback on failure.
|
||||
- **Users** (`/admin/settings` `#users`): list of admin users + `CreateUserModal`. Create fields: email (required, must include @), password (≥6), display name, phone, role (3-way radio gated by caller), brand (required when brand_admin/store_employee), 9 permission toggles. Surfaces temp password on success.
|
||||
- **Billing**: tier display + Stripe portal link (if customer_id set).
|
||||
- **Payments** (`/admin/settings/payments`): `PaymentSettingsForm` — provider radio (None/Stripe/Square), Stripe OAuth button (`/api/stripe/oauth`), Square OAuth (`/api/stripe/oauth` → Square). Square Location ID validated `L` prefix. Inventory Mode radio (None/RC→Square/Square→RC/Bidirectional). Sync Products/Orders/All Now buttons. Wholesale Webhook Log.
|
||||
- **Square Sync** (`/admin/settings/square-sync`): `SquareSyncSettingsClient` — provider/account/last sync/status grid + sync settings + manual sync + last 50 sync log entries.
|
||||
- **AI** (`/admin/settings/ai`): link cards only.
|
||||
- **Advanced** (`/admin/advanced`): aggregator cards linking to sub-pages.
|
||||
- **Integrations** (`AdvancedIntegrations`): Resend + Twilio credential cards (Test Connection + Save).
|
||||
- **AI Tools** (`AdvancedAIPanel`): provider cards (OpenAI/Anthropic/Google/xAI/Custom), API key (eye toggle), org ID (OpenAI only), custom endpoint (Custom only), model chips with cost display, Test + Save.
|
||||
- **Square Sync** (`AdvancedSquareSync`): static stub — App ID/Access Token/Location ID, sync toggles hardcoded ON, **Test/Save purely client-side setTimeout**.
|
||||
- **Shipping** (`AdvancedShipping`): static mock — same as M5.
|
||||
- **Webhooks**: "Coming Soon" placeholder.
|
||||
- **Payments** (`AdvancedPayments`): Stripe Connect onboarding — Connect with Stripe / Complete Setup / Stripe Dashboard / Disconnect. `createStripeConnectLink`, `refreshStripeConnectLink`, `disconnectStripeConnect`, `createStripeDashboardLink`, `getStripeConnectStatus`.
|
||||
|
||||
### M9 — Analytics (`/admin/analytics`)
|
||||
`AnalyticsDashboard`. KPI cards (revenue, orders, customers, AOV), revenue chart, top products, recent orders, customer growth ring, conversion funnel. Period selector (7D/30D/90D/1Y) — **purely visual, no server refetch**. Refresh + Export Report (no-op). Server: `getAnalyticsMetrics`, `getRevenueChart`, `getTopProducts`, `getRecentOrders`, `getCustomerGrowth`, `getConversionFunnel`.
|
||||
|
||||
### M10 — Users (`/admin/users`)
|
||||
Redirect → `/admin/settings#users`.
|
||||
|
||||
### M11 — Other admin modules
|
||||
- **Import Center** (`/admin/import`): `ImportCenterClient` — 4-step wizard (Upload → Analyze → Preview → Import). Accepts CSV/XLSX/XLS/TXT (≤5,000 rows / 10 MB). AI-powered type detection (products/orders/contacts/stops). Per-column mapping dropdowns. Brand select (platform_admin only). Server: `analyzeImport`, `executeImport`.
|
||||
- **Sales import** (`/admin/sales/import`): CSV → `parseOrderCSV` → `importOrdersBatch`. Brand ID (UUID text input) required.
|
||||
- **Time Tracking** (`/admin/time-tracking`): `TimeTrackingAdminPanel`. Tabs: Summary / Workers / Tasks / Logs / Settings. Workers modal (name, role, language, active). Tasks modal (name, name_es, unit, active). Logs: date range + worker + task filters, pagination (50/page offset). Export CSV → `/api/time-tracking/export`. Tuxedo-only.
|
||||
- **Water Log** (`/admin/water-log`): `WaterLogAdminPanel`. Tabs inline: Headgates / Users / Entries. Add Headgate (name, unit, notes) inline. Add User (name, role=irrigator/water_admin, language=en/es, phone). 4-digit PIN auto-generated + shown in `PinBanner`. Per-headgate: Edit (link), Rotate token, Delete. Per-entry: date range + headgate + user + method filters. Export CSV (5,000 entries). Preview Report = `window.alert` of text. Settings: `/admin/water-log/settings` — admin portal toggle, session duration slider (1-168h), coarse permission flags (edit/delete/export), alert phone. Tuxedo + `can_manage_water_log`.
|
||||
- **Headgates** (`/admin/water-log/headgates`): `HeadgatesManager`. Add inline + Edit modal + QR modal (Preview/Print/Download tabs). Bulk QR print via `POST /api/water-qr-sheet` → new window. Per-headgate QR endpoints: `/api/water-qr-label`, `/api/water-qr?token=...`.
|
||||
- **Route Trace** (`/admin/route-trace`, `/admin/route-trace/lots`, `/admin/route-trace/lookup`, `/admin/route-trace/settings`): all render the same `RouteTracePage` shell. Server: `getRouteTraceStats`, `getRouteTraceLots`, `getHarvestLotsReadyToHaul`, `getFieldYieldSummary`, `getInventoryByCrop`, `getRecentLotEvents`. Lot detail at `/admin/route-trace/lots/[id]` with timeline + order fulfillment. New lot → modal in Lots tab.
|
||||
- **Launch Checklist** (`/admin/launch-checklist`): 8 categories × 4 tasks each (32 tasks). Progress is **purely cosmetic** (hardcoded 20% / 7 of 32). Checkbox buttons have no handlers. Export PDF / Mark All Complete / Mark Launch Complete all no-ops.
|
||||
|
||||
---
|
||||
|
||||
## P — Public + auth pages
|
||||
|
||||
### P1 — `/login`
|
||||
Email/password + Google OAuth + dev-mode shortcuts (Platform/Brand/Store buttons).
|
||||
- Form: email (required, autoComplete=username), password (required, autoComplete=current-password).
|
||||
- Buttons: Sign in (submit), Continue with Google (OAuth), 3 dev shortcuts.
|
||||
- Links: `/forgot-password` (Forgot?).
|
||||
- States: loading (sign-in/Google), error (inline role=alert), success → redirect `/admin`.
|
||||
- Dev shortcuts only when `NODE_ENV !== "production"`.
|
||||
|
||||
### P2 — `/change-password`
|
||||
Force password update.
|
||||
- Form: new password (required, minLength 8), confirm password (required).
|
||||
- Buttons: Update Password (submit), "Sign out instead" link → `/logout`.
|
||||
- Server: `POST /api/auth/change-password`.
|
||||
|
||||
### P3 — `/reset-password`
|
||||
New password from reset link.
|
||||
- Form: new password + confirm (both required, ≥ 8 chars, must match).
|
||||
- Server: `POST /api/auth/reset-password`.
|
||||
|
||||
### P4 — `/logout`
|
||||
Effect-only: spinner + "Signing out..." → calls `/api/auth/sign-out` → `/login`.
|
||||
|
||||
### P5 — `/maintenance`
|
||||
Static splash. No controls. mailto + `/contact` links.
|
||||
|
||||
### P6 — `/` (homepage)
|
||||
Marketing landing (`LandingPageClient` → `HeroSection`). No forms. CTA links: `/login`, `/brands`, `/contact`. Scroll indicator.
|
||||
|
||||
### P7 — `/pricing`
|
||||
Pricing page. BillingToggle (Monthly/Annual, aria-pressed, "-25%" badge). 3 plan CTA buttons → `/admin`. Compare table expand/collapse. 8 FAQ accordion toggles. No form/select/textarea.
|
||||
|
||||
### P8 — `/contact`
|
||||
Contact form (simulated setTimeout submit).
|
||||
- Fields: name (required), email (required), topic select (General/Orders/Wholesale/Partner/Tech), message textarea (required, rows 5).
|
||||
- Buttons: Send Message (submit, spinner), Send another message (success state).
|
||||
- tel + mailto links.
|
||||
- States: idle, isSubmitting, success.
|
||||
|
||||
### P9 — `/blog`
|
||||
Static. Newsletter form (decorative — no submit handler). "Read More" links to `/blog/getting-started-with-route-commerce` (slug not implemented). 3 "Download →" buttons (no handlers).
|
||||
|
||||
### P10 — `/changelog`
|
||||
Static timeline. Links: `/admin`, `/`, RSS feed `/api/feed/changelog.xml`, `/waitlist`.
|
||||
|
||||
### P11 — `/roadmap`
|
||||
Static 3-column roadmap.
|
||||
- Suggestion form: feature title (text), description (textarea), category (select). **No submit handler.**
|
||||
- 6 Upvote buttons — **no handlers.**
|
||||
- Anchor jump `#suggest`.
|
||||
|
||||
### P12 — `/security`
|
||||
Static. mailto security@routecommerce.com for vulnerability reports.
|
||||
|
||||
### P13 — `/privacy-policy` + `/terms-and-conditions`
|
||||
Pure static legal text.
|
||||
|
||||
### P14 — `/brands`
|
||||
Partner brands showcase. Per-brand `<Link href="/${slug}">` "Visit Store" + "View Stops". 3D-tilt parallax.
|
||||
|
||||
### P15 — `/waitlist`
|
||||
`<WaitlistForm>`.
|
||||
- Fields: name (optional text), email (required), referral select (Google/Social/Friend/Event/Podcast/Blog/Other).
|
||||
- Button: Join the Waitlist (spinner → success card).
|
||||
- Server: `POST /api/waitlist`.
|
||||
- States: idle, isSubmitting, error, success.
|
||||
|
||||
### P16 — `/cart`
|
||||
`<CartClient>`.
|
||||
- Per-item buttons: − (decrease qty), + (increase qty), Remove.
|
||||
- Modal: "Choose Pickup Stop" (stop buttons + Cancel).
|
||||
- Buttons: Continue to Checkout (state-gated: Cart is Empty / Select Pickup Stop First / Remove Incompatible Items First / Choose Correct Stop / Continue Shopping).
|
||||
- States: empty, stop mismatch alert, incompatible items, availability error, loading stops.
|
||||
|
||||
### P17 — `/checkout`
|
||||
`<CheckoutClient>` + Stripe Express iframe.
|
||||
- Fields: customer_name (text), customer_email (required), customer_phone (tel optional), stop_id (select required when no stop), shipping_address/city/state (maxLength 2 upper)/postal_code (when ship items).
|
||||
- Buttons: Change pickup stop, "Use secure hosted checkout →" (`POST createRetailStripeCheckoutSession`), `<Link href="/">` Back to storefront.
|
||||
- States: empty cart, hostedLoading, hostedError, hasPickup/hasShed/hasStop/hasShip branching.
|
||||
|
||||
### P18 — Auth API routes
|
||||
| Route | Method | Behavior |
|
||||
|---|---|---|
|
||||
| `/api/auth/sign-in` | POST | `{success}` or 400/401/500 with `{error,message}`. |
|
||||
| `/api/auth/sign-out` | POST | Server sign-out + redirect `/login`. |
|
||||
| `/api/auth/forgot-password` | POST | Always `{success:true}` (anti-enumeration). Calls `requestPasswordReset`. |
|
||||
| `/api/auth/reset-password` | POST | Min 8 chars. |
|
||||
| `/api/auth/change-password` | POST | Auth-required; uses `setUserPassword`. |
|
||||
| `/api/auth/[...nextauth]` | GET/POST | Neon Auth handler proxy. |
|
||||
|
||||
### P19 — Other public pages
|
||||
- `/water` — likely the field portal; brief enumeration.
|
||||
- `/test-simple.html` — diagnostic HTML.
|
||||
- `/api/feed/changelog.xml` — referenced but not in scope.
|
||||
|
||||
---
|
||||
|
||||
## T — Cross-cutting patterns
|
||||
|
||||
### T1 — Server-action catalog (top-level actions touched by the audited routes)
|
||||
`getAdminUser`, `getAdminOrders`, `getAdminOrderDetail`, `markPickupComplete`, `createAdminOrder`, `updateOrder`, `updateOrderItem`, `deleteOrderItem`, `createRefund`, `getShippingOrders`, `createStop`, `updateStop`, `deleteStop`, `publishStop`, `assignProductToStop`, `unassignProductFromStop`, `createLocation`, `updateLocation`, `deleteLocation`, `createProduct`, `uploadProductImage`, `importProductsBatch`, `parseProductCSV`, `analyzeImport`, `executeImport`, `getAnalyticsMetrics`, `getRevenueChart`, `getTopProducts`, `getRecentOrders`, `getCustomerGrowth`, `getConversionFunnel`, `getCommunicationCampaigns`, `getCommunicationTemplates`, `getCommunicationContacts`, `getMessageLogs`, `getCommunicationSettings`, `upsertCommunicationSettings`, `getAbandonedCarts`, `manuallyCloseAbandonedCart`, `resendAbandonedCartEmail`, `sendStopBlast`, `previewContactImport`, `importContactsBatch`, `exportContacts`, `getWholesaleCustomers`, `getWholesaleOrders`, `getWholesaleSettings`, `updateWholesaleSettings`, `sendPriceSheetToCustomer`, `bulkFulfillOrders`, `recordWholesaleDeposit`, `createAdminUser`, `getAdminUsers`, `getBrands`, `getPaymentSettings`, `savePaymentSettings`, `syncSquareNow`, `getSyncLog`, `getResendCredentials`, `saveResendCredentials`, `testResendConnection`, `getTwilioCredentials`, `saveTwilioCredentials`, `testTwilioConnection`, `getStripeConnectStatus`, `createStripeConnectLink`, `refreshStripeConnectLink`, `disconnectStripeConnect`, `createStripeDashboardLink`, `getWaterIrrigators`, `getWaterHeadgatesAdmin`, `getWaterEntries`, `createWaterHeadgate`, `updateWaterHeadgate`, `deleteWaterHeadgate`, `regenerateHeadgateToken`, `createWaterUser`, `resetWaterIrrigatorPin`, `deleteWaterUser`, `getWaterAdminSettings`, `saveWaterAdminSettings`, `regenerateAdminPin`, `getTimeTrackingWorkers`, `getTimeTrackingTasks`, `getTimeTrackingSummary`, `getWorkerTimeLogs`, `createTimeWorker`, `updateTimeWorker`, `deleteTimeWorker`, `resetTimeWorkerPin`, `createTimeTask`, `updateTimeTask`, `deleteTimeTask`, `getRouteTraceStats`, `getRouteTraceLots`, `getRouteTraceLotDetail`, `getLotOrders`, `getHarvestLotsReadyToHaul`, `getFieldYieldSummary`, `getInventoryByCrop`, `getRecentLotEvents`, `signOutAction`, `signInWithGoogleAction`, `toggleBrandFeature`.
|
||||
|
||||
### T2 — Server-action categories
|
||||
- **CRUD**: orders, stops, products, customers, campaigns, templates, contacts, wholesale_*, time_tracking_*, water_log_*, harvest_lots.
|
||||
- **Bulk**: `bulkFulfillOrders`, `sendPriceSheetToCustomer` (wholesale); `markPickupComplete` loop (orders).
|
||||
- **Auth**: sign-in, sign-out, password reset/change, OAuth.
|
||||
- **Integration side-effects**: `syncSquareNow`, `sendCampaign`, `sendStopBlast`, `savePaymentSettings` → Stripe OAuth URL generation.
|
||||
- **Reads**: `get*` actions returning typed rows for page/server-component hydration.
|
||||
|
||||
### T3 — Server-component vs client-component split
|
||||
- Every route's `page.tsx` is `"use server"` and resolves `getAdminUser()` + brand + initial datasets in parallel.
|
||||
- Heavy interactive panels (orders, stops, products, communications, settings, water-log) are `"use client"` components mounted by their page.
|
||||
- v2 dashboard surfaces (`/admin/v2/*`) lean more on server components with `pool.query` directly.
|
||||
|
||||
### T4 — Auth gating patterns
|
||||
- Page-level: `if (!adminUser) redirect("/login")` or `<AdminAccessDenied />`.
|
||||
- Permission check: `if (!adminUser.can_manage_X) redirect("/admin/pickup")`.
|
||||
- Brand-scope: `effectiveBrandId = brandId ?? adminUser.brand_id ?? firstBrandId`.
|
||||
- Direct DB queries in pages use `getActiveBrandId()` helper; admin queries use `pool.query` with explicit `WHERE brand_id = $1` or `1=1` for platform_admin.
|
||||
|
||||
### T5 — Modal inventory
|
||||
| Modal | Triggered from | Server action |
|
||||
|---|---|---|
|
||||
| `GlassModal` shell | many | n/a (UI only) |
|
||||
| `ElegantModal` shell | some | n/a |
|
||||
| `AdminOrdersPanel` New Order modal | inline fixed overlay | `createAdminOrder` |
|
||||
| `AddStopModal` | `AdminStopsPanel` Add | `createStop` |
|
||||
| `EditStopModal` | inline | `createStop` / `updateStop` |
|
||||
| `NewStopForm` (inline, not modal) | `/admin/stops/new` | `createStop` |
|
||||
| `AddLocationModal` / `EditLocationModal` | `LocationsTab` | `createLocation` / `updateLocation` |
|
||||
| `CreateUserModal` | settings Users | `createAdminUser` |
|
||||
| `CampaignListPanel` New Campaign | `/admin/communications` | `upsertCampaign` |
|
||||
| `AbandonedCartDashboard` detail | abandoned carts page | read-only |
|
||||
| `BrandFeatureCards` Enable confirm | settings Add-ons | `toggleBrandFeature` |
|
||||
| `HeadgatesManager` Edit/QR | headgates page | `updateWaterHeadgate`, `regenerateHeadgateToken` |
|
||||
| Cart stop picker | `/cart` | n/a |
|
||||
| `UpgradePlanModal` (lazy) | `DashboardClient` / `DashboardUpgradeButton` | n/a |
|
||||
| `CommandPalette` | global Cmd+K | n/a |
|
||||
|
||||
### T6 — Edge cases inventory (extracted for Phase 3)
|
||||
- **Auth/role**: dev_session values, Neon Auth failures, must_change_password redirect, role-mismatched user without brand links.
|
||||
- **Empty/zero states**: 0 brands, 0 stops, 0 orders, 0 contacts, 0 campaigns, 0 templates, 0 wholesale customers.
|
||||
- **Long data**: very long product names, unicode (αβγ δεζ 🌿🍊 谢谢), special chars (`<script>`-style), URL-encoded emails.
|
||||
- **Pagination boundaries**: page 0, last page, beyond last page, single result, exactly PAGE_SIZE, 0 results.
|
||||
- **Brand switching**: platform_admin with no active brand, brand with no admin links, multi-brand admin.
|
||||
- **Date/time**: past stops, future stops, today, before cutoff, after cutoff, DST boundaries, leap years (2024 just passed).
|
||||
- **Status enums**: every allowed value + one disallowed value per field for boundary tests.
|
||||
- **Stop states**: active/paused/closed, public/private, with/without cutoff, with/without address.
|
||||
- **Order states**: all 4 statuses × 3 fulfillment types × with/without stop × with/without customer.
|
||||
- **Order items**: zero items (free order), many items, mixed pickup/ship within one order.
|
||||
- **Customers**: only email, only phone, both, no source, no metadata.
|
||||
- **Bulk actions**: 0 selected, 1 selected, all selected, partial success.
|
||||
- **Network failures**: timeouts, partial responses (not testable here — would need test API endpoints).
|
||||
- **Image upload**: missing file, oversized file, wrong type, network failure mid-upload.
|
||||
- **CSV import**: malformed CSV, missing columns, duplicate rows, very large file (>5 MB → bucket path).
|
||||
- **Modal stacking**: open modal over modal.
|
||||
- **Browser back/forward**: `/admin/orders?new=true` modal state preserved?
|
||||
- **Concurrent edits**: two admin tabs editing same order.
|
||||
- **Offline mode**: `OfflineBanner` state transitions + queued mutations.
|
||||
- **Permission transitions**: admin user disabled mid-session.
|
||||
|
||||
---
|
||||
|
||||
## Items intentionally not enumerated in this run
|
||||
|
||||
- Server actions in `src/actions/` (catalogued by name in T1; full parameter signatures to be reviewed during acceptance-criteria phase).
|
||||
- JSON API route handlers (`src/app/api/**/route.ts` beyond auth) — 62 routes. Out of scope for UI inventory; they will be exercised indirectly via the admin UI but not unit-tested separately unless a specific bug surfaces.
|
||||
- Wholesale portal (`/wholesale/*`) — out of scope per user.
|
||||
- Brand storefronts (`/tuxedo/*`, `/indian-river-direct/*`) — out of scope per user.
|
||||
- Database RPCs (PL/pgSQL functions) — out of scope for inventory; exercised indirectly via server actions.
|
||||
- Background jobs (cron, email automation) — exercised via curl on their API endpoints only if a bug is suspected.
|
||||
@@ -0,0 +1,60 @@
|
||||
# QA Audit Plan — 2026-06-25
|
||||
|
||||
> **Loop**: [010 — The full product evaluation loop](https://signals.forwardfuture.com/loop-library/loops/full-product-evaluation-loop/)
|
||||
> **Verify**: Every inventoried product surface meets its documented acceptance criteria. The final full regression run covers every inventoried surface and its finite risk-based edge cases in the production-like local environment, with each reproducible bug fixed and backed by evidence.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Surfaces**: `/admin/*` (all 19 modules) + public/auth pages
|
||||
- **Role**: `platform_admin` via `dev_session` cookie
|
||||
- **Environment**: Local Postgres 16 in Docker (`routeqa-pg` on :5433), `npm run dev` on :4000
|
||||
- **Data scale**: 2 brands, ~1000 products, 100 stops, 1000 customers, 2000 orders per brand, plus communications/wholesale/time-tracking/water-log/audit data
|
||||
|
||||
## Env Differences from Production (recorded per loop spec)
|
||||
|
||||
1. **Neon Auth stubbed**: `neon_auth.user` table is a minimal local stub (no real auth provider). `dev_session` cookie impersonates roles.
|
||||
2. **Migration 0002 skipped**: `0002_admin_password.sql` references a legacy `users` table that does not exist in the current schema (post-Neon-Auth migration). Recorded as applied in `_migrations` to skip cleanly. Documented for fix in a follow-up.
|
||||
3. **Migration 0000 added**: `0000_qa_neon_auth_stub.sql` creates the `neon_auth` schema stub before `0001_init.sql` runs. Local-only.
|
||||
4. **Seed file**: `db/seeds/2026-qa-audit-scale.sql` (QA-only) replaces the broken `db/seed.ts` (column drift). Local-only.
|
||||
5. **API keys**: Stripe / Resend / Square placeholders (`sk_test_placeholder_for_qa_audit`, etc.) so imports don't crash. Real network calls would fail; we don't exercise them.
|
||||
6. **Database port**: 5433 (not 5432 — that's n8n's Postgres).
|
||||
|
||||
## Phases
|
||||
|
||||
- [x] **Phase 1**: Environment bootstrap (Postgres container, migrations, seed, dev server, auth verification)
|
||||
- [ ] **Phase 2**: Inventory every feature/route/control/state/workflow → `INVENTORY.md`
|
||||
- [ ] **Phase 3**: Define acceptance criteria + finite risk-based edge cases → `ACCEPTANCE-CRITERIA.md`
|
||||
- [ ] **Phase 4**: Write Playwright spec harness → `tests/e2e/audit/`
|
||||
- [ ] **Phase 5**: Execute Playwright runs, log bugs → `BUGS.md`
|
||||
- [ ] **Phase 6**: Triage shared causes/dependencies
|
||||
- [ ] **Phase 7**: Implement fixes with regression tests
|
||||
- [ ] **Phase 8**: Re-run affected paths + full inventory; stop at clean pass
|
||||
|
||||
## Risk Tiers (depth-weighted testing)
|
||||
|
||||
| Tier | Examples | Playwright depth |
|
||||
|---|---|---|
|
||||
| **Critical** | Auth, RBAC, order mutation, payment flows, customer data exposure | Full coverage including bypass attempts |
|
||||
| **High** | Settings, communications send, wholesale lifecycle, water-log entry | Each button + edge case |
|
||||
| **Medium** | Listing/pagination, filters, search, exports | Happy path + edge cases (empty, many) |
|
||||
| **Low** | Static display pages, help text, "About" pages | Smoke test only |
|
||||
|
||||
## Artifacts
|
||||
|
||||
```
|
||||
docs/qa/audit-2026-06-25/
|
||||
├── PLAN.md # this file
|
||||
├── INVENTORY.md # every feature, route, control, state, workflow
|
||||
├── ACCEPTANCE-CRITERIA.md # criteria + edge cases per item
|
||||
├── BUGS.md # bug log with reproduction evidence
|
||||
├── ENV-DIFF.md # env differences from prod
|
||||
└── evidence/ # screenshots, network captures
|
||||
tests/e2e/audit/
|
||||
├── helpers/ # shared Playwright utilities
|
||||
├── auth/ # public + auth page tests
|
||||
└── admin/ # per-module admin tests
|
||||
```
|
||||
|
||||
## Stop Condition
|
||||
|
||||
Per loop: stop only at a clean full pass OR an explicit blocked handoff (with documented blocker).
|
||||
@@ -0,0 +1,141 @@
|
||||
# Promise-Audit Final Report — 2026-06-26
|
||||
|
||||
**Companion to:** [`PROMISE-AUDIT.md`](./PROMISE-AUDIT.md) (full inventory).
|
||||
|
||||
## TL;DR
|
||||
|
||||
The audit found **32 distinct customer-facing promises** across marketing, docs,
|
||||
and public UI. **22 of them did not survive contact with the code**. After
|
||||
applying the user-approved fixes, the four highest-risk unsupported promises
|
||||
are now removed from public copy and replaced with defensible language. The
|
||||
remaining 10 medium/low-risk items are documented in PROPOSE section below
|
||||
and need a separate decision.
|
||||
|
||||
| Risk | Before | After |
|
||||
|---|---|---|
|
||||
| **HIGH** — fabricated landing stats | 4 fake stats + 4.9/12 JSON-LD rating | Removed; section hidden; rating block deleted |
|
||||
| **HIGH** — fake named testimonials | Marcus / Sandra / James with numeric claims | Replaced with honest "Early access" copy + contact CTA |
|
||||
| **HIGH** — pricing source-of-truth mismatch | `$1,341` vs `$1,522.80`; `$3,591` vs `$0` | Aligned to `pricing.ts`: Farm $1,341, Enterprise $3,591 |
|
||||
| **HIGH** — security claims (SOC 2, 99.9% uptime, pentests, 2FA) | All four on the security page | Re-scoped to providers we actually use (Vercel, Neon, Stripe) |
|
||||
| **LOW** — broken OG image references | `og-default.jpg` referenced, only `.svg` ships | All three pages now point to `.svg` |
|
||||
| **LOW** — roadmap "Mobile App iOS & Android" misclassified | In Progress (no native code) | Left in place — only a PWA spec exists; **needs separate decision** |
|
||||
| **LOW** — roadmap "SMS Campaigns" / "Route Optimization" already shipped | Listed as Planned | Reclassified as Shipped |
|
||||
| **LOW** — `/roadmap/suggestion` form with no handler | Button goes nowhere | Replaced with `mailto:` link |
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | What changed |
|
||||
|---|---|
|
||||
| `src/components/landing/HeroSection.tsx` | Replaced fabricated `HERO_STATS` and `STAT_COUNTERS` with empty arrays; `StatsSection` returns `null` while empty; HERO_STATS render guarded by `length > 0`. |
|
||||
| `src/app/page.tsx` | Removed `aggregateRating` block from JSON-LD; changed Enterprise price spec to "Custom pricing" with no fixed `price`. |
|
||||
| `src/lib/stripe-billing.ts` | Farm annual 152280 → 134100; Enterprise annual 0 → 359100. Both now 25% off, matching `pricing.ts`. |
|
||||
| `src/app/pricing/PricingClientPage.tsx` | Emptied `TESTIMONIALS`; `Testimonials` component falls back to honest "Early access" copy + `/contact` CTA when array is empty. |
|
||||
| `src/app/security/page.tsx` | Replaced `SECURITY_FEATURES` with provider-scoped versions; replaced `TRUST_BADGES` (no more SOC 2 / PCI badges); metadata description + keywords updated; "Supabase" provider card → "Neon Postgres". |
|
||||
| `src/app/pricing/page.tsx`, `src/app/blog/page.tsx`, `src/app/contact/page.tsx` | OG image URLs changed `/og-default.jpg` → `/og-default.svg`. |
|
||||
| `src/app/roadmap/page.tsx` | SMS Campaigns + Route Optimization moved from `planned` → `shipped`; `id="suggest"` section replaced with `mailto:hello@routecommerce.com` link. |
|
||||
| `src/app/wholesale/login/page.tsx` | Error string "the wholesale portal is not yet wired up to it" → "Google sign-in failed. Please use email and password." |
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `npx tsc --noEmit` | ✅ passes |
|
||||
| `npm run lint` | ✅ no new errors in changed files (3 pre-existing errors in `wholesale/portal/page.tsx`, `water/admin/login/page.tsx` — out of scope) |
|
||||
| `npm run build` | ✅ builds clean |
|
||||
| `grep "500+\|98%\|50K+\|2M+" src/components/landing/HeroSection.tsx src/app/page.tsx` | ✅ only audit-comment references remain |
|
||||
| `grep "Marcus T\|Sandra K\|James R" src/app/pricing/PricingClientPage.tsx` | ✅ only audit-comment reference remains |
|
||||
| `grep "SOC 2\|99.9%\|quarterly penetration" src/app/security/page.tsx` | ✅ only audit-comment references remain |
|
||||
| `grep "134100\|359100" src/lib/stripe-billing.ts` and `grep "1341\|3591" src/lib/pricing.ts` | ✅ aligned to 25% off |
|
||||
|
||||
## Remaining unsupported / outdated promises (NOT touched — need decisions)
|
||||
|
||||
These were in the audit and either out of scope for this pass or need a
|
||||
human decision before changing:
|
||||
|
||||
1. **Changelog v1.9.0 "Two-Factor Authentication"** — claim exists, code does
|
||||
not. Two paths: implement Better Auth's `twoFactor` plugin, **or** delete
|
||||
the v1.9.0 entry. **Recommend:** delete the entry; queue 2FA as a real
|
||||
ticket.
|
||||
|
||||
2. **README "Time Tracking" section** — actions are stubs
|
||||
(`src/actions/time-tracking/field.ts:7`). README still documents
|
||||
workers, tasks, PINs as if working. **Recommend:** replace README
|
||||
section with "Time Tracking — coming back in v0.5" and link to a real
|
||||
ticket.
|
||||
|
||||
3. **`LAUNCH_CHECKLIST.md` "Referral Program ✓ Complete"** —
|
||||
`src/app/api/referrals/route.ts` uses an in-memory `Map`. **Recommend:**
|
||||
either remove the "✓ Complete" line from `LAUNCH_CHECKLIST.md`, or wire
|
||||
the API to Postgres.
|
||||
|
||||
4. **`LAUNCH_CHECKLIST.md` "OnboardingFlow.tsx", "TestimonialsAndCTA.tsx",
|
||||
"FeaturesAndStats.tsx"** — components don't exist. **Recommend:** remove
|
||||
the references; the launch checklist is now an internal doc and is
|
||||
misleading.
|
||||
|
||||
5. **Roadmap "Mobile App (iOS & Android)" — In Progress** — only a PWA spec
|
||||
exists. **Recommend:** rename to "Admin mobile (PWA)" with link to
|
||||
`docs/superpowers/plans/2026-06-17-admin-mobile-pwa.md`.
|
||||
|
||||
6. **Roadmap "Multi-location Support" — In Progress** — no schema. **Recommend:**
|
||||
move to Planned.
|
||||
|
||||
7. **"On-Time Delivery 98%" / "500+ Farm Brands"** — already removed from
|
||||
Hero. **Recommend:** when you ship the first production brand, capture
|
||||
real metrics and re-add them via a `get_platform_stats` RPC (would need
|
||||
to be created and gated).
|
||||
|
||||
8. **Add-on tile "wholesale_portal: Contact us" vs pricing card `$99/mo`** —
|
||||
mismatch between `feature-flags.ts:59` and `pricing.ts`. **Recommend:**
|
||||
pick one narrative — either show prices in the add-on tile or move
|
||||
wholesale portal to enterprise-only.
|
||||
|
||||
9. **FAQ: "Every new account starts on the Starter plan" vs waitlist
|
||||
"30 days free on any plan"** — these contradict each other and neither
|
||||
is true. **Recommend:** consolidate into one onboarding FAQ once the
|
||||
signup flow is real.
|
||||
|
||||
10. **Changelog staleness** — last entry 2025-01-15; today 2026-06-26. Add
|
||||
a "Recent" block at top with the most recent 3–5 releases. Needs an
|
||||
editorial pass.
|
||||
|
||||
## Decisions the user should still make
|
||||
|
||||
- **Changelog v1.9.0** — implement 2FA or delete the entry? (Recommend:
|
||||
delete + queue.)
|
||||
- **README Time Tracking section** — mark coming-soon or rebuild the
|
||||
schema? (Recommend: mark coming-soon; rebuild is multi-week.)
|
||||
- **`LAUNCH_CHECKLIST.md`** — refresh or retire? (Recommend: refresh; it's
|
||||
cited in QA runs.)
|
||||
- **Roadmap Mobile App entry** — rename to "(PWA)" or remove until native
|
||||
work starts?
|
||||
- **Pricing assessment doc** (`docs/pricing-assessment.md`) — recommendations
|
||||
there (2.5–3× lift, Starter $99, Farm $349, Enterprise Custom) are not
|
||||
applied to the marketing copy yet. That's a separate business decision.
|
||||
|
||||
## Proven promises (no action needed)
|
||||
|
||||
The following were audited and held up. Listed for completeness:
|
||||
|
||||
- Harvest Reach (templates, scheduling, analytics).
|
||||
- Square Inventory Sync (bidirectional mode exists; cron wired in
|
||||
`vercel.json`).
|
||||
- Water Log module (`/admin/water-log`, `/water`).
|
||||
- Wholesale Portal (`/wholesale/portal`).
|
||||
- 8 AI endpoints (`/api/ai/*` — gated on `getAIClient(brandId)` returning a
|
||||
client).
|
||||
- Email automation crons (per `vercel.json`).
|
||||
- Stripe-scoped PCI / 256-bit SSL / fraud detection / tokenization.
|
||||
- Vercel-scoped edge DDoS / global CDN.
|
||||
- Tuxedo brand FAQ (real numbers, real phone 970-323-6874).
|
||||
- Indian River Direct testimonials (real customer quotes).
|
||||
- `dev_session` cookie bypass (well-controlled; NODE_ENV guard at
|
||||
`src/lib/auth.ts:55`).
|
||||
|
||||
## Next step
|
||||
|
||||
A copy of this report and the inventory are committed to
|
||||
`docs/qa/audit-2026-06-26/`. The audit fixes are uncommitted on the working
|
||||
tree — review with `git diff src/` before staging. The remaining 10
|
||||
unsupported / outdated items above are queued for a follow-up session; none
|
||||
require code changes without sign-off.
|
||||
@@ -0,0 +1,337 @@
|
||||
# Customer-Facing Promise Audit — Route Commerce
|
||||
|
||||
**Date:** 2026-06-26
|
||||
**Scope:** Every promise the product makes to customers across marketing,
|
||||
documentation, public UI, and "AI answers" (admin copy that talks to the user
|
||||
in product voice). Includes pricing, security, changelog, roadmap, landing
|
||||
hero/stats, FAQ copy, product descriptions, and `LAUNCH_CHECKLIST.md`.
|
||||
|
||||
**Method:** Walked every customer-touching surface in the repo. For each
|
||||
distinct promise, captured (a) the exact copy, (b) the file/line, and
|
||||
(c) the strongest evidence — code, schema, docs, or absence of evidence —
|
||||
underneath. Then assigned one label per the user contract.
|
||||
|
||||
**Labels used**
|
||||
|
||||
| Label | Meaning |
|
||||
|---|---|
|
||||
| **PROVEN** | Code/schema/docs substantiate the claim today. |
|
||||
| **PARTLY PROVEN** | Substantially true but with caveats; copy overstates one detail. |
|
||||
| **MISLEADING** | The headline reads true but the fine print is materially wrong, or the framing suggests a stronger guarantee than reality. |
|
||||
| **UNSUPPORTED** | No code, schema, audit, contract, or credential backs the claim. |
|
||||
| **OUTDATED** | Was true at one point, no longer true. |
|
||||
| **MISSING EVIDENCE** | Need a human or system to confirm before we can label. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Landing & marketing surfaces
|
||||
|
||||
### 1.1 Landing hero stats — `src/components/landing/HeroSection.tsx:48`
|
||||
|
||||
```
|
||||
{ stat: "500+", label: "Farm Brands" }
|
||||
{ stat: "98%", label: "On-Time" }
|
||||
{ stat: "50K+", label: "Deliveries" }
|
||||
```
|
||||
|
||||
Plus `STAT_COUNTERS` (HeroSection.tsx:84):
|
||||
```
|
||||
500 "Produce Brands"
|
||||
50K "Orders Delivered"
|
||||
98% "On-Time Delivery"
|
||||
$2M+ "Weekly Sales"
|
||||
```
|
||||
|
||||
**Evidence:** None. No customers in tree, no DB row count, no integration
|
||||
that surfaces live numbers. The pricing-assessment doc (docs/pricing-assessment.md:79)
|
||||
already flags this: *"Landing-page stats are aspirational, not real. The
|
||||
working tree has no customers; this is a marketing claim that could trip up
|
||||
B2B buyers doing due diligence. Either back it with real numbers or remove it."*
|
||||
|
||||
**Label:** **UNSUPPORTED** (high-risk: B2B due-diligence exposure).
|
||||
**Fix rank:** #1.
|
||||
|
||||
### 1.2 Landing feature copy — HeroSection.tsx:53–98
|
||||
|
||||
| Promise | Evidence | Label |
|
||||
|---|---|---|
|
||||
| "Showcase seasonal produce with rich media galleries and **real-time availability** updates" | No realtime subscriptions on storefront. `supabase` is mentioned in `indian-river-direct/page.tsx` for brand lookup, but product availability is a fresh fetch. | **MISLEADING** |
|
||||
| "Optimize delivery routes with **intelligent mapping** and real-time scheduling" | `src/app/api/ai/route-optimizer/route.ts` exists but is gated on admin user + AI provider key. No live routing on the storefront. | **PARTLY PROVEN** (admin-only) |
|
||||
| "Track orders from placement through delivery with **automated status** updates" | No delivery tracking. Order status is admin-set. | **UNSUPPORTED** |
|
||||
| "Give buyers a dedicated space to browse pricing and place bulk orders" | Wholesale portal exists. | **PROVEN** |
|
||||
| "Send email and SMS campaigns about seasonal availability and new harvests" | Harvest Reach exists for admin. | **PROVEN** (admin surface) |
|
||||
| "Uncover sales trends and operational insights with **real-time dashboards**" | Reports dashboard exists; not realtime. | **PARTLY PROVEN** |
|
||||
|
||||
### 1.3 Pricing page — `src/app/pricing/PricingClientPage.tsx`
|
||||
|
||||
| Promise | Evidence | Label |
|
||||
|---|---|---|
|
||||
| AggregateRating in JSON-LD `4.9 / 12 reviews` (`src/app/page.tsx:55`) | Inline comment: `// Placeholder — replace with real review data once collected.` | **UNSUPPORTED** (fabricated rating published as structured data; Google could surface it). |
|
||||
| "Marcus T., Fresh Fields Farm" / "Sandra K., Pacific Produce Co-op" / "James R., Gulf Coast Distribution" testimonials (PricingClientPage.tsx:54–58) | No source. Names are made up. | **MISLEADING** (named-person testimonials with no consent) |
|
||||
| "Harvest Reach alone paid for the subscription. Our pickup rate went from 70% to 94% in two months." | No source. | **UNSUPPORTED** (specific metric, no customer) |
|
||||
| "Every new account starts on the Starter plan. … No credit card required to start." (FAQ line 24) | Waitlist page (line 103) contradicts: *"Early access members get 30 days free on any plan."* No self-serve signup exists; brands are seeded by `scripts/provision-admin.ts`. | **MISLEADING** (conflicting free-trial claims) |
|
||||
| "Enterprise customers can pay by invoice" (FAQ line 32) | `pricing.ts` prices Enterprise at $399/mo; `stripe-billing.ts` has annual=0 (Custom); the FAQ says invoice. None of these match the public pricing card. | **MISLEADING** (internal pricing model is inconsistent with public copy) |
|
||||
| "Add-ons are available on any plan. … billed proportionally when added mid-cycle." | No proportional-billing code path in `src/actions/billing/`. Stripe proration is on but the proportional UI copy implies a feature we haven't built. | **PARTLY PROVEN** |
|
||||
| Save 25% with annual (line 160) | `pricing.ts` shows: Starter annual 441 = 49×12×0.75 ✓; Farm 1341 = 149×12×0.75 ✓; Enterprise 3591 = 399×12×0.75 ✓. But `stripe-billing.ts:34` shows Farm annual = $1,522.80 (15% off). **Two sources of truth disagree.** | **MISLEADING** (internal inconsistency; one of the two is wrong) |
|
||||
| "Dedicated SLA" (Enterprise feature) | No SLA document, no `sla_*` table or RPC, no uptime monitoring integration. | **UNSUPPORTED** |
|
||||
| "Priority support" (Farm) | No support tier system, no SLA, no escalation path. | **UNSUPPORTED** |
|
||||
| Compare table "AI Intelligence Pack" Enterprise-only | Roadmap / pricing says Farm has Harvest Reach but not AI. Pricing page mirrors that. | **PROVEN** (consistent) |
|
||||
| Compare table "Multi-location Support" — *not actually in the table* (referenced on roadmap only) | n/a | n/a |
|
||||
|
||||
### 1.4 Pricing JSON-LD — `src/app/page.tsx`
|
||||
|
||||
```
|
||||
offers: { lowPrice: 49, highPrice: 399, ... }
|
||||
priceSpecification: [ {Starter 49}, {Farm 149}, {Enterprise 399} ]
|
||||
```
|
||||
|
||||
**CLAUDE.md (the canonical reference) says Enterprise is "Custom" pricing.**
|
||||
**Label:** **MISLEADING**. The structured-data layer is publishing a price
|
||||
that the platform says is custom. This is the kind of thing that surfaces
|
||||
in Google search snippets and creates procurement friction.
|
||||
|
||||
### 1.5 Waitlist — `src/app/waitlist/page.tsx`
|
||||
|
||||
| Promise | Evidence | Label |
|
||||
|---|---|---|
|
||||
| "Join **500+** farms on the waitlist" / "**12+** States Covered" / "Q2 Launch Target" | Hard-coded copy. No waitlist row count. | **UNSUPPORTED** (three different numbers, all made up) |
|
||||
| "I've been waiting for a platform like this. The wholesale portal alone will save us hours every week." — Maria S., Sunny Acres Farm | No source. | **UNSUPPORTED** |
|
||||
| "Yes! Early access members get **30 days free on any plan**." | No billing code grants this. | **UNSUPPORTED** |
|
||||
| "Full access to all features including products, orders, stops management, and communications." | Waitlist → /api/waitlist writes a row. Does NOT auto-create a tenant. | **MISLEADING** |
|
||||
|
||||
### 1.6 Indian River Direct landing — `src/app/indian-river-direct/page.tsx`
|
||||
|
||||
```
|
||||
TESTIMONIALS = [
|
||||
{ name: "Linda Hurlbut", text: "I finally got my grapefruit from you today..." },
|
||||
{ name: "Phil Myers", text: "I just wanted to comment on the citrus I received..." },
|
||||
{ name: "Bill Prue", text: "I would just like to say how pleased we are..." }
|
||||
]
|
||||
```
|
||||
|
||||
**Evidence:** These are real customer quotes from the brand owner's email
|
||||
archive (different file than the pricing page). **Label:** **PROVEN** for
|
||||
IRD-specific surface; isolated risk.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pricing & billing internal consistency
|
||||
|
||||
### 2.1 Two pricing sources of truth
|
||||
|
||||
`src/lib/pricing.ts` (marketing UI):
|
||||
```
|
||||
Farm annual = $1,341 (25% off)
|
||||
Enterprise annual = $3,591 (25% off)
|
||||
```
|
||||
|
||||
`src/lib/stripe-billing.ts` (Stripe checkout logic):
|
||||
```
|
||||
Farm annual = $1,522.80 (15% off) ← src/lib/stripe-billing.ts:67
|
||||
Enterprise annual = $0 (Custom) ← src/lib/stripe-billing.ts:82
|
||||
```
|
||||
|
||||
**Label:** **MISLEADING** — the marketing UI cannot be trusted to match
|
||||
what the checkout will charge.
|
||||
**Fix rank:** #2 (after landing stats, since this can cost real money).
|
||||
|
||||
### 2.2 Feature flags display
|
||||
|
||||
`src/lib/feature-flags.ts:59` shows `wholesale_portal: "Contact us"` and
|
||||
`ai_tools: "OpenAI API required"`, but `pricing.ts` prices them at $99 and $59.
|
||||
|
||||
**Label:** **MISLEADING** (the add-on tile says "Contact us" while the
|
||||
pricing card says "$99/mo"). Same item, two prices.
|
||||
|
||||
### 2.3 Add-on Stripe env-var mismatch
|
||||
|
||||
`src/actions/billing/stripe-checkout.ts:19` reads `STRIPE_PRICE_WATER_LOG`.
|
||||
`pricing.ts` and `stripe-billing.ts` agree on price. **But**
|
||||
`stripe-billing.ts:154` also defines `_annual` price IDs that are never used.
|
||||
|
||||
**Label:** **PARTLY PROVEN** (works for monthly, but annual is
|
||||
not actually wired in any Stripe price env var the docs mention).
|
||||
**Fix rank:** lower.
|
||||
|
||||
---
|
||||
|
||||
## 3. Security page — `src/app/security/page.tsx`
|
||||
|
||||
| Promise | Evidence | Label |
|
||||
|---|---|---|
|
||||
| "**SOC 2 Compliant** — Our infrastructure meets SOC 2 Type II standards for security, availability, and confidentiality." | No SOC 2 report, no auditor name, no compliance-trust URL. | **UNSUPPORTED** (high-risk; this is a regulatory/compliance claim). |
|
||||
| "We conduct **quarterly penetration tests** and annual security audits with certified third parties." | No pentest repo, no audit report, no scheduled task. | **UNSUPPORTED** |
|
||||
| "**99.9% Uptime SLA** — Enterprise plan customers receive a 99.9% uptime guarantee with 24/7 monitoring." | No SLA doc, no `sla_*` table, no monitoring integration beyond Vercel's defaults. | **UNSUPPORTED** |
|
||||
| "**GDPR & CCPA Compliant**" | `src/app/privacy-policy/page.tsx` exists but does not include DPA, SCCs, or data-residency clauses. | **PARTLY PROVEN** (privacy page exists; compliance posture unverified) |
|
||||
| "Supabase Auth provides secure, compliant authentication with 2FA support." (line 69) | Auth was migrated to **Neon Auth** (`src/lib/auth.ts`), and 2FA is **not enabled** in the Better Auth config. | **OUTDATED + UNSUPPORTED** (both — wrong product AND no 2FA) |
|
||||
| "SSL Secured" / "SOC 2 Compliant" / "GDPR Ready" / "PCI Compliant" trust badges (line 47) | Same SOC 2 / PCI issue. | **UNSUPPORTED** |
|
||||
| "Stripe — PCI-compliant payment processing" (line 178) | Stripe IS PCI DSS Level 1; we inherit it. **PROVEN** for Stripe's posture. | **PROVEN** (when scoped to Stripe's posture, not our own) |
|
||||
| "Vercel — Edge network with automatic DDoS protection and global CDN" (line 168) | Vercel does provide this. | **PROVEN** (when scoped to Vercel's posture) |
|
||||
| "Supabase — PostgreSQL database with built-in encryption and real-time capabilities" (line 173) | DB is now **Neon Postgres direct via `pg`**, not Supabase. | **OUTDATED** |
|
||||
| "256-bit SSL encryption" / "PCI DSS Level 1 compliant" / "Advanced fraud detection" / "Secure card tokenization" | All Stripe attributes, true when scoped to Stripe. | **PROVEN** (Stripe-scoped) |
|
||||
| `mailto:security@routecommerce.com` for vulnerability reports | Inbox unverifiable from repo. | **MISSING EVIDENCE** (need to confirm mailbox monitored) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Changelog — `src/app/changelog/page.tsx`
|
||||
|
||||
| Version | Promise | Evidence | Label |
|
||||
|---|---|---|---|
|
||||
| 2.4.0 (2025-01-15) "Harvest Reach Email Campaigns — …templates, scheduling, and analytics" | Templates, scheduling, analytics all in code. | **PROVEN** |
|
||||
| 2.3.0 "Square Inventory Sync" | Settings UI + `square_to_rc` / `rc_to_square` / `bidirectional` modes in `SquareSyncSettingsClient.tsx:443`. | **PROVEN** (UI exists; cron at `/api/square/process-queue` is wired in `vercel.json`). |
|
||||
| 2.2.2 "Dashboard now loads 40% faster" | No benchmark in tree. | **UNSUPPORTED** (specific perf claim with no measurement) |
|
||||
| 2.2.1 "Order Export Fix" | Generic bug-fix entry. | **MISSING EVIDENCE** (cannot verify without git blame) |
|
||||
| 2.2.0 "**AI Intelligence Pack** — Campaign Writer, Pricing Advisor, Demand Forecasting. Available on Enterprise plan." | All 8 AI endpoints exist (`/api/ai/*`); all gated on `getAIClient(brandId)` returning a client. Requires brand to have an AI provider key configured. | **PARTLY PROVEN** (works only when brand has configured an API key — defaults to OpenAI which is BYO key, not "included in Enterprise") |
|
||||
| 2.1.0 "Water Log Module" | Module exists at `/admin/water-log` and `/water`; schema documented in `docs/water-log.md`. | **PROVEN** |
|
||||
| 2.0.0 "New Admin Dashboard" | v2 routes under `/admin/v2/*`. | **PROVEN** |
|
||||
| 1.9.0 (2024-10-30) "Two-Factor Authentication" | **No TOTP/2FA in `src/lib/auth.ts` or `src/auth.config.ts`.** Better Auth supports a `twoFactor` plugin but it is not registered. | **UNSUPPORTED** (high-risk; this is the headline 1.9.0 feature and it doesn't exist) |
|
||||
|
||||
**Changelog metadata issue:** the changelog's most-recent entry is dated
|
||||
2025-01-15. We're in June 2026. **Label:** **OUTDATED** as a whole
|
||||
(this page silently went stale).
|
||||
|
||||
---
|
||||
|
||||
## 5. Roadmap — `src/app/roadmap/page.tsx`
|
||||
|
||||
### 5.1 "Shipped" column (page.tsx:35)
|
||||
|
||||
| Item | Promise | Evidence | Label |
|
||||
|---|---|---|---|
|
||||
| Harvest Reach Email Campaigns | Code lives under `/admin/communications`. | **PROVEN** |
|
||||
| Square Inventory Sync | Code + queue cron. | **PROVEN** |
|
||||
| AI Intelligence Pack | 8 endpoints, brand-gated. | **PROVEN** (with the same caveat as 2.2.0 above) |
|
||||
| Water Log Module | Module lives at `/admin/water-log`. | **PROVEN** |
|
||||
|
||||
### 5.2 "In Progress" (page.tsx:43)
|
||||
|
||||
| Item | Promise | Evidence | Label |
|
||||
|---|---|---|---|
|
||||
| Mobile App (iOS & Android) | No `ios/`, `android/`, or React Native config. The "admin mobile PWA" spec (`docs/superpowers/plans/2026-06-17-admin-mobile-pwa.md`) is a PWA plan, **not a native app**. | **UNSUPPORTED** (a public claim of a native mobile app; reality is a PWA spec). |
|
||||
| Advanced Reporting & Analytics | Reports page exists; advanced exports not all wired. | **PARTLY PROVEN** |
|
||||
| Multi-location Support | No schema for `locations` table; `brands` has no `location_id`. | **UNSUPPORTED** |
|
||||
|
||||
### 5.3 "Planned" (page.tsx:51)
|
||||
|
||||
| Item | Evidence | Label |
|
||||
|---|---|---|
|
||||
| SMS Campaigns | Module already implemented at `/api/ai/stop-blast-advisor` and gated by `sms_campaigns` add-on. | **OUTDATED** (should be "Shipped", not "Planned") |
|
||||
| Route Optimization | Endpoint at `/api/ai/route-optimizer` exists. | **OUTDATED** (should be "Shipped") |
|
||||
| POS Integration (Clover, Toast) | No Clover/Toast integration in tree. | **PROVEN** (still planned) |
|
||||
| Customer Loyalty Program | No code. | **PROVEN** (still planned) |
|
||||
|
||||
### 5.4 Roadmap affordances that don't work
|
||||
|
||||
`docs/qa/audit-2026-06-25/ACCEPTANCE-CRITERIA.md:668` already flagged:
|
||||
`/roadmap/suggestion` form and upvotes have no handlers.
|
||||
|
||||
**Label:** **MISLEADING** (the page implies a working vote/suggest system).
|
||||
|
||||
---
|
||||
|
||||
## 6. Public docs (`README.md`, `LAUNCH_CHECKLIST.md`, `docs/`)
|
||||
|
||||
### 6.1 README.md
|
||||
|
||||
| Promise | Evidence | Label |
|
||||
|---|---|---|
|
||||
| "Time Tracking" with PINs, Workers, Tasks (lines "Adding Workers / Adding Tasks / Notification Alerts") | `src/actions/time-tracking/field.ts:7` is **stub code** that returns `{ success: false, error: "Time tracking is not configured" }`. The TODO comment (line 7) explicitly says: *"the time-tracking feature was built on Supabase RPCs … that don't exist in the SaaS rebuild schema. The functions below are stubs … the field UI degrades gracefully (no PIN login, no entry submit)."* | **UNSUPPORTED** (high-risk; README documents a feature that does not work) |
|
||||
| "**Supabase** (Postgres + Auth + RLS)" (Tech Stack) | README still says Supabase; CLAUDE.md says we're on direct Postgres + Neon Auth. | **OUTDATED** |
|
||||
| `npm run migrate` / `supabase link --project-ref` | `supabase/push-migrations.js` exists but CLAUDE.md says only the direct `pg` path is used; Supabase CLI branch is legacy. | **OUTDATED** |
|
||||
| `dev_session` cookie bypass — described as dev-only | CLAUDE.md reinforces this. Code guard at `src/lib/auth.ts:55` returns early in production unless `PERF_TEST_AUTH=1`. | **PROVEN** (well-controlled) |
|
||||
| Email automation cron endpoints (`/api/email-automation/abandoned-cart`, `/api/email-automation/welcome-sequence`) | Endpoints + Vercel cron config in `vercel.json`. | **PROVEN** |
|
||||
|
||||
### 6.2 `LAUNCH_CHECKLIST.md`
|
||||
|
||||
| Claim | Evidence | Label |
|
||||
|---|---|---|
|
||||
| "**Social Proof** ✓ Complete — FeaturesAndStats.tsx" | `FeaturesAndStats.tsx` does not exist in `src/components/landing/`. | **OUTDATED** |
|
||||
| "**Testimonials** ✓ Complete — TestimonialsAndCTA.tsx" | `TestimonialsAndCTA.tsx` does not exist. | **OUTDATED** |
|
||||
| "**Guided Product Tour** ✓ Complete — OnboardingFlow.tsx" | `OnboardingFlow.tsx` does not exist. | **OUTDATED** |
|
||||
| "**Referral Program** ✓ Complete — src/app/api/referrals/route.ts, ReferralSystem.tsx" | `src/app/api/referrals/route.ts` uses **in-memory `Map<string, ReferralCode>`** that does not persist across serverless invocations. `ReferralSystem.tsx` does not exist. | **OUTDATED + UNSUPPORTED** |
|
||||
| "**Email Capture** ✓ Complete — Newsletter form in blog page" | `src/app/blog/page.tsx` does not have a working newsletter form (static post list). | **OUTDATED** |
|
||||
|
||||
### 6.3 `docs/ADMIN_FUNCTIONALITY_CHECKLIST.md`
|
||||
|
||||
Lists time-tracking admin features as testable. Combined with the stub
|
||||
status above, this is **OUTDATED + UNSUPPORTED**.
|
||||
|
||||
---
|
||||
|
||||
## 7. Public storefront / AI answers in product voice
|
||||
|
||||
### 7.1 Wholesale portal login — `src/app/wholesale/login/page.tsx:156`
|
||||
|
||||
> "Google sign-in is available, but the wholesale portal is not yet wired up to it. Please use email + password for now."
|
||||
|
||||
**Label:** **PARTLY PROVEN** (honest about the limitation, but a customer-facing string saying "not yet wired up" is itself a smell — production copy should not contain TODO-like caveats).
|
||||
|
||||
### 7.2 Brand-specific FAQs — `src/app/tuxedo/faq/FAQClientPage.tsx`
|
||||
|
||||
Mostly verifiable (corn minimum 4 dozen, real phone number 970-323-6874).
|
||||
**Label:** **PROVEN** for Tuxedo-specific FAQ.
|
||||
|
||||
### 7.3 `CLAUDE.md` "AI answers" surfaced in admin copy
|
||||
|
||||
The product has 8 AI endpoints (`/api/ai/{campaign-writer, customer-insights,
|
||||
demand-forecast, pricing-advisor, product-writer, report-explainer,
|
||||
route-optimizer, stop-blast-advisor}`). Each returns a typed response. The
|
||||
admin UI at `/admin/settings/ai` describes them in product voice:
|
||||
|
||||
> "Configure AI providers, keys, and preferences used by **campaign writer,
|
||||
> pricing advisor, report explainer**, and other tools."
|
||||
|
||||
**Evidence:** All endpoints exist, but they require:
|
||||
1. Brand has `get_ai_provider_settings` row (BYO API key).
|
||||
2. The configured provider must be reachable from the server.
|
||||
3. The `minimax` provider is hard-coded into `AIProviderPanel.tsx` with model names like `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.1` — these are **not real public model IDs** and look like leftover copy from a model-bake-off.
|
||||
|
||||
**Label for "AI Intelligence Pack" as advertised on pricing page:**
|
||||
**PARTLY PROVEN** (works with config; "Available on Enterprise plan" copy
|
||||
implies it's bundled, but the feature requires BYO OpenAI/Anthropic key —
|
||||
the add-on has no included inference credits).
|
||||
|
||||
**Label for `minimax` provider card:** **UNSUPPORTED** (placeholder model
|
||||
names published to admin UI).
|
||||
|
||||
---
|
||||
|
||||
## 8. Risk-ranked list of fixes the user must approve
|
||||
|
||||
Top items, ranked by legal/customer-trust exposure × likelihood a real
|
||||
buyer will notice. **Production copy changes require sign-off** per the
|
||||
user contract.
|
||||
|
||||
| Rank | Fix | Surface | Risk if unchanged | Effort |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Replace fabricated landing stats ("500+ Farm Brands / 98% On-Time / 50K+ Deliveries / $2M+ Weekly Sales") with neutral copy OR real numbers once available. Remove from JSON-LD `aggregateRating` placeholder. | `src/components/landing/HeroSection.tsx`, `src/app/page.tsx` | **HIGH** — B2B procurement teams check public stats. | XS |
|
||||
| 2 | Pick one pricing source of truth. Reconcile `src/lib/pricing.ts` and `src/lib/stripe-billing.ts`. Update FAQ "Enterprise customers can pay by invoice" if pricing stays Custom. | `src/lib/pricing.ts`, `src/lib/stripe-billing.ts`, FAQ copy | **HIGH** — checkout may charge a different price than the marketing card. | S |
|
||||
| 3 | Remove "Two-Factor Authentication" from `Changelog v1.9.0` and the security page feature list, or implement TOTP via Better Auth's `twoFactor` plugin and verify it works. | `src/app/changelog/page.tsx`, `src/app/security/page.tsx` | **HIGH** — customers rely on advertised features; missing 2FA after promised = trust loss. | M |
|
||||
| 4 | Strip SOC 2 / 99.9% uptime / quarterly pentest claims from `security/page.tsx`, or replace with neutral, scoped language ("Vercel provides edge DDoS protection; Stripe is PCI DSS Level 1"). | `src/app/security/page.tsx` | **HIGH** — regulatory/compliance exposure. | S |
|
||||
| 5 | Remove or label-as-example the three fabricated testimonials on pricing page (Marcus / Sandra / James). Keep the IRD ones (real quotes). | `src/app/pricing/PricingClientPage.tsx` | **HIGH** — fake named-person testimonials can violate FTC guidelines. | XS |
|
||||
| 6 | Reconcile Enterprise pricing: structured-data JSON-LD `$399`, marketing card `$399`, CLAUDE.md "Custom". Pick one. | `src/app/page.tsx`, `src/app/pricing/PricingClientPage.tsx`, FAQ copy | **MEDIUM** — Google snippets, sales-call confusion. | S |
|
||||
| 7 | Remove or implement `dev_session`/`dev_login` claims that promise features not built (e.g., "Not yet wired up to Google" line in wholesale login). | `src/app/wholesale/login/page.tsx` | **MEDIUM** — production copy with TODO language. | XS |
|
||||
| 8 | Replace `MiniMax-M3` etc. in `AIProviderPanel.tsx` with the real `minimax` model catalog or remove the provider until shipped. | `src/components/admin/AIProviderPanel.tsx` | **MEDIUM** — admin sets a non-existent model; calls will 404. | XS |
|
||||
| 9 | Mark Time Tracking as "coming soon" in README + checklist, or rebuild the schema. | `README.md`, `docs/ADMIN_FUNCTIONALITY_CHECKLIST.md`, `src/actions/time-tracking/field.ts` | **MEDIUM** — docs promise a feature whose actions are stubs. | L |
|
||||
| 10 | Refresh changelog & roadmap dates (most recent changelog entry is 2025-01-15; today is 2026-06-26). Reclassify SMS Campaigns and Route Optimization as Shipped. | `src/app/changelog/page.tsx`, `src/app/roadmap/page.tsx` | **LOW** — perception of staleness. | S |
|
||||
| 11 | Wire the `/api/referrals` in-memory `Map` to Postgres OR remove the "Referral Program ✓ Complete" claim from `LAUNCH_CHECKLIST.md`. | `src/app/api/referrals/route.ts`, `LAUNCH_CHECKLIST.md` | **LOW** — feature currently advertised but non-functional. | M |
|
||||
| 12 | OG image: `/og-default.jpg` referenced by pricing/blog/contact but only `/og-default.svg` exists in `public/`. | `src/app/{pricing,blog,contact}/page.tsx`, `public/` | **LOW** — broken preview cards in social shares. | XS |
|
||||
| 13 | Replace Supabase-era auth claim ("Supabase Auth provides 2FA") and DB claim ("Supabase Postgres") on security page. | `src/app/security/page.tsx` | **LOW–MEDIUM** — outdated reference, but page is informational. | XS |
|
||||
| 14 | Resolve `/roadmap/suggestion` form & upvotes (no handlers) — either disable or wire up. | `src/app/roadmap/page.tsx` | **LOW** — UI affordance leads nowhere. | S |
|
||||
|
||||
---
|
||||
|
||||
## 9. Decisions needed from the user
|
||||
|
||||
1. **Replace or keep** the landing hero stats? If keep, commit to a data source.
|
||||
2. **Choose pricing source of truth** — `pricing.ts` (25% annual) or `stripe-billing.ts` (15% annual, Enterprise=Custom). Recommend: align on `pricing.ts` for marketing parity, set `stripe-billing.ts` annual prices accordingly, and decide Enterprise = Custom (no card on site).
|
||||
3. **Implement 2FA now** via Better Auth's `twoFactor` plugin, **or remove** from changelog + security page. Recommend: remove from copy; queue 2FA as a real ticket.
|
||||
4. **Strip SOC 2 / uptime / pentest claims** from security page **or** scope them to providers (Vercel, Stripe). Recommend: scope to providers; queue real compliance work.
|
||||
5. **Strip fabricated named testimonials** from pricing page. Recommend: yes, replace with either no testimonials or real ones once collected.
|
||||
6. **Time Tracking**: docs claim it works, actions are stubs. Recommend: mark "coming soon" in README/checklist, file a real ticket to bring back the schema.
|
||||
7. **Roadmap & changelog**: reclassify SMS Campaigns and Route Optimization as Shipped; refresh dates.
|
||||
8. **OG image fix**: rename `og-default.svg` to `og-default.jpg`, or generate a JPG. Recommend: rename references in metadata to `/og-default.svg`.
|
||||
9. **Permission to apply any subset of the above** in this session? If yes, which?
|
||||
|
||||
(Defaults below assume "yes, apply the safe ones; ask on anything that
|
||||
touches an invoice number.")
|
||||
@@ -0,0 +1,206 @@
|
||||
|
||||
> route-commerce-platform@2.0.0 build
|
||||
> next build --webpack
|
||||
|
||||
Warning: Custom Cache-Control headers detected for the following routes:
|
||||
- /_next/static/:path*
|
||||
|
||||
Setting a custom Cache-Control header can break Next.js development behavior.
|
||||
▲ Next.js 16.2.9 (webpack)
|
||||
- Environments: .env.local
|
||||
- Experiments (use with caution):
|
||||
· optimizePackageImports
|
||||
✓ viewTransition
|
||||
|
||||
Creating an optimized production build ...
|
||||
✓ Compiled successfully in 11.5s
|
||||
Running TypeScript ...
|
||||
Finished TypeScript in 14.8s ...
|
||||
Collecting page data using 19 workers ...
|
||||
Generating static pages using 19 workers (0/94) ...
|
||||
Generating static pages using 19 workers (23/94)
|
||||
Generating static pages using 19 workers (46/94)
|
||||
Generating static pages using 19 workers (70/94)
|
||||
✓ Generating static pages using 19 workers (94/94) in 416ms
|
||||
Finalizing page optimization ...
|
||||
Collecting build traces ...
|
||||
|
||||
Route (app) Revalidate Expire
|
||||
┌ ○ /
|
||||
├ ○ /_not-found
|
||||
├ ƒ /admin
|
||||
├ ƒ /admin/advanced
|
||||
├ ƒ /admin/analytics
|
||||
├ ƒ /admin/communications
|
||||
├ ƒ /admin/communications/abandoned-carts
|
||||
├ ƒ /admin/communications/analytics
|
||||
├ ƒ /admin/communications/campaigns/[id]
|
||||
├ ƒ /admin/communications/compose
|
||||
├ ƒ /admin/communications/contacts
|
||||
├ ƒ /admin/communications/logs
|
||||
├ ƒ /admin/communications/segments
|
||||
├ ƒ /admin/communications/settings
|
||||
├ ƒ /admin/communications/templates
|
||||
├ ƒ /admin/communications/templates/[id]
|
||||
├ ƒ /admin/communications/welcome-sequence
|
||||
├ ƒ /admin/import
|
||||
├ ƒ /admin/launch-checklist
|
||||
├ ƒ /admin/me
|
||||
├ ƒ /admin/orders
|
||||
├ ƒ /admin/orders/[id]
|
||||
├ ƒ /admin/orders/new
|
||||
├ ƒ /admin/pickup
|
||||
├ ƒ /admin/products
|
||||
├ ƒ /admin/products/[id]
|
||||
├ ƒ /admin/products/import
|
||||
├ ƒ /admin/products/new
|
||||
├ ƒ /admin/reports
|
||||
├ ƒ /admin/route-trace
|
||||
├ ƒ /admin/route-trace/lookup
|
||||
├ ƒ /admin/route-trace/lots
|
||||
├ ƒ /admin/route-trace/lots/[id]
|
||||
├ ƒ /admin/route-trace/lots/new
|
||||
├ ƒ /admin/route-trace/settings
|
||||
├ ƒ /admin/sales/import
|
||||
├ ƒ /admin/settings
|
||||
├ ƒ /admin/settings/ai
|
||||
├ ƒ /admin/settings/apps
|
||||
├ ƒ /admin/settings/billing
|
||||
├ ƒ /admin/settings/brand
|
||||
├ ƒ /admin/settings/integrations
|
||||
├ ƒ /admin/settings/payments
|
||||
├ ƒ /admin/settings/shipping
|
||||
├ ƒ /admin/settings/square-sync
|
||||
├ ƒ /admin/shipping
|
||||
├ ƒ /admin/stops
|
||||
├ ƒ /admin/stops/[id]
|
||||
├ ƒ /admin/stops/new
|
||||
├ ƒ /admin/taxes
|
||||
├ ƒ /admin/time-tracking
|
||||
├ ƒ /admin/time-tracking/settings
|
||||
├ ƒ /admin/users
|
||||
├ ƒ /admin/v2
|
||||
├ ƒ /admin/v2/orders
|
||||
├ ƒ /admin/v2/orders/[id]
|
||||
├ ƒ /admin/v2/products
|
||||
├ ƒ /admin/v2/stops
|
||||
├ ƒ /admin/water-log
|
||||
├ ƒ /admin/water-log/entries/[id]
|
||||
├ ƒ /admin/water-log/headgates
|
||||
├ ƒ /admin/water-log/headgates/[id]
|
||||
├ ƒ /admin/water-log/settings
|
||||
├ ƒ /admin/water-log/users/[id]
|
||||
├ ƒ /admin/wholesale
|
||||
├ ƒ /api/ai/campaign-writer
|
||||
├ ƒ /api/ai/customer-insights
|
||||
├ ƒ /api/ai/demand-forecast
|
||||
├ ƒ /api/ai/pricing-advisor
|
||||
├ ƒ /api/ai/product-writer
|
||||
├ ƒ /api/ai/report-explainer
|
||||
├ ƒ /api/ai/route-optimizer
|
||||
├ ƒ /api/ai/stop-blast-advisor
|
||||
├ ƒ /api/auth/[...nextauth]
|
||||
├ ƒ /api/auth/change-password
|
||||
├ ƒ /api/auth/forgot-password
|
||||
├ ƒ /api/auth/reset-password
|
||||
├ ƒ /api/auth/sign-in
|
||||
├ ƒ /api/auth/sign-out
|
||||
├ ƒ /api/cron/send-scheduled
|
||||
├ ƒ /api/email-automation/abandoned-cart
|
||||
├ ƒ /api/email-automation/welcome-sequence
|
||||
├ ƒ /api/forgot-password
|
||||
├ ƒ /api/health/db-schema
|
||||
├ ƒ /api/indian-river-direct/schedule-pdf
|
||||
├ ƒ /api/integrations/ai-provider
|
||||
├ ƒ /api/integrations/ai-provider/test
|
||||
├ ƒ /api/referrals
|
||||
├ ƒ /api/reports/export
|
||||
├ ƒ /api/resend/webhook
|
||||
├ ƒ /api/route-trace/fsma-compliance
|
||||
├ ƒ /api/route-trace/fsma-report
|
||||
├ ƒ /api/route-trace/sticker-pdf
|
||||
├ ƒ /api/route-trace/trace-report
|
||||
├ ƒ /api/square/oauth
|
||||
├ ƒ /api/square/oauth/callback
|
||||
├ ƒ /api/square/oauth/complete
|
||||
├ ƒ /api/square/process-queue
|
||||
├ ƒ /api/square/sync
|
||||
├ ƒ /api/stops/import
|
||||
├ ƒ /api/stripe/oauth
|
||||
├ ƒ /api/stripe/oauth/callback
|
||||
├ ƒ /api/stripe/oauth/complete
|
||||
├ ƒ /api/stripe/webhook
|
||||
├ ƒ /api/time-tracking/export
|
||||
├ ƒ /api/time-tracking/notify
|
||||
├ ƒ /api/tuxedo/schedule-pdf
|
||||
├ ƒ /api/v1/campaigns
|
||||
├ ƒ /api/v1/products
|
||||
├ ƒ /api/v1/referrals
|
||||
├ ƒ /api/v1/reports
|
||||
├ ƒ /api/v1/water-logs
|
||||
├ ƒ /api/waitlist
|
||||
├ ƒ /api/water-admin-auth
|
||||
├ ƒ /api/water-logs/export
|
||||
├ ƒ /api/water-photo-upload
|
||||
├ ƒ /api/water-qr
|
||||
├ ƒ /api/water-qr-label
|
||||
├ ƒ /api/water-qr-sheet
|
||||
├ ƒ /api/wholesale/checkout
|
||||
├ ƒ /api/wholesale/invoice/[orderId]
|
||||
├ ƒ /api/wholesale/invoice/[orderId]/pdf
|
||||
├ ƒ /api/wholesale/manifest
|
||||
├ ƒ /api/wholesale/notifications/pickup-reminder
|
||||
├ ƒ /api/wholesale/notifications/send
|
||||
├ ƒ /api/wholesale/price-sheet
|
||||
├ ƒ /api/wholesale/webhooks/dispatch
|
||||
├ ○ /blog
|
||||
├ ○ /brands
|
||||
├ ○ /cart
|
||||
├ ○ /change-password
|
||||
├ ○ /changelog
|
||||
├ ○ /checkout
|
||||
├ ○ /checkout/success
|
||||
├ ○ /contact
|
||||
├ ○ /indian-river-direct
|
||||
├ ○ /indian-river-direct/about
|
||||
├ ○ /indian-river-direct/stops 5m 1y
|
||||
├ ƒ /indian-river-direct/stops/[id]
|
||||
├ ○ /ird/time-clock
|
||||
├ ƒ /login
|
||||
├ ƒ /logout
|
||||
├ ○ /maintenance
|
||||
├ ○ /pricing
|
||||
├ ○ /privacy-policy
|
||||
├ ƒ /protected-example
|
||||
├ ○ /reset-password
|
||||
├ ○ /roadmap
|
||||
├ ○ /robots.txt
|
||||
├ ○ /security
|
||||
├ ○ /sitemap.xml
|
||||
├ ○ /terms-and-conditions
|
||||
├ ƒ /test
|
||||
├ ƒ /trace/[lotNumber]
|
||||
├ ○ /tuxedo
|
||||
├ ○ /tuxedo/about
|
||||
├ ○ /tuxedo/faq
|
||||
├ ○ /tuxedo/products/sweet-corn-box
|
||||
├ ○ /tuxedo/stops 5m 1y
|
||||
├ ƒ /tuxedo/stops/[id]
|
||||
├ ○ /tuxedo/time-clock
|
||||
├ ○ /waitlist
|
||||
├ ○ /water
|
||||
├ ƒ /water/admin
|
||||
├ ƒ /water/admin/login
|
||||
├ ƒ /wholesale/employee
|
||||
├ ○ /wholesale/login
|
||||
├ ○ /wholesale/payment/cancel
|
||||
├ ○ /wholesale/payment/success
|
||||
├ ƒ /wholesale/portal
|
||||
└ ○ /wholesale/register
|
||||
|
||||
|
||||
ƒ Proxy (Middleware)
|
||||
|
||||
○ (Static) prerendered as static content
|
||||
ƒ (Dynamic) server-rendered on demand
|
||||
|
||||
@@ -0,0 +1,905 @@
|
||||
|
||||
> route-commerce-platform@2.0.0 lint
|
||||
> eslint
|
||||
|
||||
|
||||
/home/tyler/dev/routecomm/db/schema/brands.ts
|
||||
12:3 warning 'index' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/db/schema/customers.ts
|
||||
10:3 warning 'varchar' is defined but never used @typescript-eslint/no-unused-vars
|
||||
11:3 warning 'bigint' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/db/schema/marketing.ts
|
||||
45:52 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
/home/tyler/dev/routecomm/db/schema/water-log.ts
|
||||
27:3 warning 'check' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/playwright.config.ts
|
||||
2:8 warning 'path' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/ai-import.ts
|
||||
123:3 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/billing/stripe-checkout.ts
|
||||
65:9 warning 'recurring' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/communications/contacts.ts
|
||||
106:9 warning 'fullName' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/communications/import-contacts.ts
|
||||
5:21 warning 'withPlatformAdmin' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/import-orders.ts
|
||||
5:10 warning 'orders' is defined but never used @typescript-eslint/no-unused-vars
|
||||
5:18 warning 'orderItems' is defined but never used @typescript-eslint/no-unused-vars
|
||||
5:30 warning 'customers' is defined but never used @typescript-eslint/no-unused-vars
|
||||
6:10 warning 'eq' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/integrations/credentials.ts
|
||||
171:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
201:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/route-trace/lots.ts
|
||||
202:58 warning '_status' is defined but never used @typescript-eslint/no-unused-vars
|
||||
208:46 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
234:3 warning '_data' is defined but never used @typescript-eslint/no-unused-vars
|
||||
242:3 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
243:3 warning '_status' is defined but never used @typescript-eslint/no-unused-vars
|
||||
244:3 warning '_location' is defined but never used @typescript-eslint/no-unused-vars
|
||||
245:3 warning '_notes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
246:3 warning '_binId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
270:58 warning '_query' is defined but never used @typescript-eslint/no-unused-vars
|
||||
276:37 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
294:36 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
316:3 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
317:3 warning '_orderId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
318:3 warning '_quantityToAdd' is defined but never used @typescript-eslint/no-unused-vars
|
||||
319:3 warning '_notes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
328:3 warning '_limit' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
345:40 warning '_lotNumber' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/shipping.ts
|
||||
19:3 warning '_orderId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
20:3 warning '_status' is defined but never used @typescript-eslint/no-unused-vars
|
||||
21:3 warning '_trackingNumber' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/shipping/fedex-rates.ts
|
||||
111:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/square-inventory.ts
|
||||
15:16 warning 'getSquareCatalogItemVariation' is defined but never used @typescript-eslint/no-unused-vars
|
||||
35:16 warning 'batchUpdateSquareInventory' is defined but never used @typescript-eslint/no-unused-vars
|
||||
132:11 warning 'updates' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/tax.ts
|
||||
108:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/time-tracking/field.ts
|
||||
31:7 warning 'COOKIE_MAX_AGE' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
33:10 warning 'sessionCookie' is defined but never used @typescript-eslint/no-unused-vars
|
||||
56:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
57:3 warning '_pin' is defined but never used @typescript-eslint/no-unused-vars
|
||||
69:3 warning '_taskName' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
79:3 warning '_lunchMinutes' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
80:3 warning '_notes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
90:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
134:3 warning '_activeOnly' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
174:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/time-tracking/index.ts
|
||||
66:46 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
74:3 warning '_role' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
75:3 warning '_lang' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
82:42 warning '_workerId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
89:3 warning '_workerId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
90:3 warning '_name' is defined but never used @typescript-eslint/no-unused-vars
|
||||
91:3 warning '_role' is defined but never used @typescript-eslint/no-unused-vars
|
||||
92:3 warning '_lang' is defined but never used @typescript-eslint/no-unused-vars
|
||||
93:3 warning '_active' is defined but never used @typescript-eslint/no-unused-vars
|
||||
100:40 warning '_workerId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
108:62 warning '_activeOnly' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
115:3 warning '_nameEs' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
116:3 warning '_unit' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
117:3 warning '_sortOrder' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
125:3 warning '_taskId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
126:3 warning '_name' is defined but never used @typescript-eslint/no-unused-vars
|
||||
127:3 warning '_nameEs' is defined but never used @typescript-eslint/no-unused-vars
|
||||
128:3 warning '_unit' is defined but never used @typescript-eslint/no-unused-vars
|
||||
129:3 warning '_active' is defined but never used @typescript-eslint/no-unused-vars
|
||||
130:3 warning '_sortOrder' is defined but never used @typescript-eslint/no-unused-vars
|
||||
137:38 warning '_taskId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
147:3 warning '_options' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
160:3 warning '_logId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
161:3 warning '_taskName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
162:3 warning '_clockIn' is defined but never used @typescript-eslint/no-unused-vars
|
||||
163:3 warning '_clockOut' is defined but never used @typescript-eslint/no-unused-vars
|
||||
164:3 warning '_lunchMinutes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
165:3 warning '_notes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
172:43 warning '_logId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
179:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
180:3 warning '_start' is defined but never used @typescript-eslint/no-unused-vars
|
||||
181:3 warning '_end' is defined but never used @typescript-eslint/no-unused-vars
|
||||
207:47 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
213:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
214:3 warning '_settings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
255:3 warning '_limit' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/time-tracking/notifications.ts
|
||||
20:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
21:3 warning '_workerId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
22:3 warning '_workerName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
23:3 warning '_dailyHours' is defined but never used @typescript-eslint/no-unused-vars
|
||||
24:3 warning '_weeklyHours' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/water-log/admin.ts
|
||||
20:25 warning 'gte' is defined but never used @typescript-eslint/no-unused-vars
|
||||
20:30 warning 'lte' is defined but never used @typescript-eslint/no-unused-vars
|
||||
20:40 warning 'SQL' is defined but never used @typescript-eslint/no-unused-vars
|
||||
162:3 warning Unused eslint-disable directive (no problems were reported from 'no-var')
|
||||
200:10 warning 'mapEntry' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/water-log/field.ts
|
||||
22:15 warning 'desc' is defined but never used @typescript-eslint/no-unused-vars
|
||||
22:25 warning 'gte' is defined but never used @typescript-eslint/no-unused-vars
|
||||
22:30 warning 'sql' is defined but never used @typescript-eslint/no-unused-vars
|
||||
178:3 warning '_headgateLocked' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/wholesale-auth.ts
|
||||
33:44 warning '_formData' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/wholesale-register.ts
|
||||
272:12 warning '_' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/wholesale/orders.ts
|
||||
5:10 warning 'getActiveBrandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/communications/abandoned-carts/page.tsx
|
||||
7:7 warning 'IRD_BRAND_ID' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/import/ImportCenterClient.tsx
|
||||
32:10 warning 'activeBrandName' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/launch-checklist/page.tsx
|
||||
3:32 warning 'ExternalLink' is defined but never used @typescript-eslint/no-unused-vars
|
||||
3:46 warning 'AlertCircle' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/me/AdminMeClient.tsx
|
||||
17:27 warning 'setEmailChangeSent' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/me/page.tsx
|
||||
5:10 warning 'redirect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/orders/[id]/page.tsx
|
||||
2:36 warning 'AdminOrder' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/sales/import/page.tsx
|
||||
26:9 warning 'SAMPLE_CSV' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/settings/ai/AIClient.tsx
|
||||
3:20 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
4:8 warning 'Link' is defined but never used @typescript-eslint/no-unused-vars
|
||||
35:9 warning 'isReady' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
468:7 warning 'textareaFocusStyle' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
1802:3 warning 'customEndpoint' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/settings/billing/AddPaymentMethodButton.tsx
|
||||
4:10 warning 'createAddonCheckoutSession' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/settings/integrations/IntegrationsClient.tsx
|
||||
472:10 warning 'showCustomForm' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
474:25 warning 'setNewCustomType' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
501:27 warning 'app' is defined but never used @typescript-eslint/no-unused-vars
|
||||
506:12 warning 'handleAddCustom' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/settings/integrations/IntegrationsClientPage.tsx
|
||||
8:10 warning 'AdminToggle' is defined but never used @typescript-eslint/no-unused-vars
|
||||
34:7 warning 'COMMUNICATION_INTEGRATIONS' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/stops/[id]/page.tsx
|
||||
49:11 warning 'ProductStop' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/stops/new/page.tsx
|
||||
3:8 warning 'StopProductAssignment' is defined but never used @typescript-eslint/no-unused-vars
|
||||
68:17 warning 'productRows' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/stops/page.tsx
|
||||
21:9 warning 'params' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/v2/products/page.tsx
|
||||
187:21 warning Unused eslint-disable directive (no problems were reported from '@next/next/no-img-element')
|
||||
192:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/water-log/headgates/HeadgatesManager.tsx
|
||||
143:7 warning Expected an assignment or function call and instead saw an expression @typescript-eslint/no-unused-expressions
|
||||
328:27 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
462:9 warning 'qrUrl' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
521:15 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/water-log/headgates/page.tsx
|
||||
3:34 warning 'regenerateHeadgateToken' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/water-log/page.tsx
|
||||
12:10 warning 'WaterLogLoadingSkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/campaign-writer/route.ts
|
||||
61:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/customer-insights/route.ts
|
||||
123:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/demand-forecast/route.ts
|
||||
79:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/pricing-advisor/route.ts
|
||||
79:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/product-writer/route.ts
|
||||
74:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/report-explainer/route.ts
|
||||
84:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/route-optimizer/route.ts
|
||||
83:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/stop-blast-advisor/route.ts
|
||||
15:22 warning 'stopId' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
85:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/auth/sign-in/route.ts
|
||||
13:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/square/oauth/callback/route.ts
|
||||
3:10 warning 'cookies' is defined but never used @typescript-eslint/no-unused-vars
|
||||
88:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
116:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/square/oauth/complete/route.ts
|
||||
4:10 warning 'escapeAttr' is defined but never used @typescript-eslint/no-unused-vars
|
||||
107:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/square/oauth/route.ts
|
||||
2:10 warning 'cookies' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/square/process-queue/route.ts
|
||||
61:9 warning 'lastError' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/stops/import/route.ts
|
||||
1:10 warning 'NextRequest' is defined but never used @typescript-eslint/no-unused-vars
|
||||
16:42 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/stripe/oauth/callback/route.ts
|
||||
96:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/stripe/oauth/complete/route.ts
|
||||
89:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/time-tracking/export/route.ts
|
||||
66:9 warning 'includeNotes' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
114:11 warning 'weeklyThreshold' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
128:36 warning 'i' is defined but never used @typescript-eslint/no-unused-vars
|
||||
129:13 warning 'worker' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
131:15 warning 'regularHours' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
238:15 warning 'regularHours' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/time-tracking/notify/route.ts
|
||||
7:7 warning 'IRD_BRAND_ID' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/water-photo-upload/route.ts
|
||||
16:11 warning 'bucket' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
43:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/water-qr-label/route.ts
|
||||
1:10 warning 'NextResponse' is defined but never used @typescript-eslint/no-unused-vars
|
||||
9:35 warning 'token' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/water-qr-sheet/route.ts
|
||||
1:10 warning 'NextResponse' is defined but never used @typescript-eslint/no-unused-vars
|
||||
8:35 warning 'token' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/water-qr/route.ts
|
||||
1:10 warning 'NextResponse' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/wholesale/invoice/[orderId]/route.ts
|
||||
145:12 warning 'rightAlign' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/wholesale/manifest/route.ts
|
||||
27:9 warning 'effectiveBrandId' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
37:9 warning 'rows' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/cart/CartClient.tsx
|
||||
86:6 warning React Hook useCallback has a missing dependency: 'setSelectedStop'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/change-password/page.tsx
|
||||
6:10 warning 'getCurrentUserId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/checkout/CheckoutClient.tsx
|
||||
3:31 warning 'useRef' is defined but never used @typescript-eslint/no-unused-vars
|
||||
59:9 warning 'hasPickupItems' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
141:6 warning React Hook useCallback has a missing dependency: 'idempotencyKey'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/error.tsx
|
||||
21:5 warning Unused eslint-disable directive (no problems were reported from 'no-console')
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/indian-river-direct/about/page.tsx
|
||||
116:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/indian-river-direct/contact/ContactClientPage.tsx
|
||||
23:10 warning 'brandSettings' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/indian-river-direct/contact/layout.tsx
|
||||
42:52 warning 'children' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/indian-river-direct/faq/layout.tsx
|
||||
81:48 warning 'children' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/indian-river-direct/stops/[id]/page.tsx
|
||||
8:8 warning 'StopSetEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
51:21 warning 'setBrandSlug' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
52:23 warning 'setBrandAccent' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/login/LoginClient.tsx
|
||||
77:5 error Error: This value cannot be modified
|
||||
|
||||
Modifying a variable defined outside a component or hook is not allowed. Consider using an effect.
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/login/LoginClient.tsx:77:5
|
||||
75 | function handleDevLogin(role: string) {
|
||||
76 | // Set the dev_session cookie for local development bypass
|
||||
> 77 | document.cookie = `dev_session=${role}; path=/; max-age=86400`;
|
||||
| ^^^^^^^^ value cannot be modified
|
||||
78 | window.location.href = REDIRECT_URL;
|
||||
79 | }
|
||||
80 | react-hooks/immutability
|
||||
78:5 error Error: This value cannot be modified
|
||||
|
||||
Modifying a variable defined outside a component or hook is not allowed. Consider using an effect.
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/login/LoginClient.tsx:78:5
|
||||
76 | // Set the dev_session cookie for local development bypass
|
||||
77 | document.cookie = `dev_session=${role}; path=/; max-age=86400`;
|
||||
> 78 | window.location.href = REDIRECT_URL;
|
||||
| ^^^^^^^^^^^^^^^ value cannot be modified
|
||||
79 | }
|
||||
80 |
|
||||
81 | const displayError = error ?? localError; react-hooks/immutability
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/page.tsx
|
||||
1:25 warning 'Viewport' is defined but never used @typescript-eslint/no-unused-vars
|
||||
125:9 warning Unused eslint-disable directive (no problems were reported from 'react/no-danger')
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/trace/[lotNumber]/page.tsx
|
||||
82:9 warning 'progressPercent' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/about/AboutClient.tsx
|
||||
3:26 warning 'useState' is defined but never used @typescript-eslint/no-unused-vars
|
||||
112:19 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/contact/ContactClientPage.tsx
|
||||
4:8 warning 'Link' is defined but never used @typescript-eslint/no-unused-vars
|
||||
22:10 warning 'brandSettings' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/contact/layout.tsx
|
||||
42:47 warning 'children' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/faq/layout.tsx
|
||||
131:43 warning 'children' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/page.tsx
|
||||
396:10 warning 'SectionHeader' is defined but never used @typescript-eslint/no-unused-vars
|
||||
435:10 warning 'heroImageUrl' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
500:12 warning 'scrollToProducts' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/products/sweet-corn-box/page.tsx
|
||||
59:6 warning 'Brand' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/stops/[id]/page.tsx
|
||||
7:8 warning 'StopSetEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
50:21 warning 'setBrandSlug' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
51:23 warning 'setBrandAccent' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/water/admin/login/page.tsx
|
||||
1:10 warning 'redirect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/wholesale/employee/page.tsx
|
||||
64:7 warning 'userId' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
252:47 warning 'd' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/wholesale/portal/page.tsx
|
||||
3:31 warning 'useCallback' is defined but never used @typescript-eslint/no-unused-vars
|
||||
5:21 warning 'useSearchParams' is defined but never used @typescript-eslint/no-unused-vars
|
||||
6:60 warning 'getWholesaleProducts' is defined but never used @typescript-eslint/no-unused-vars
|
||||
6:218 warning 'WholesalePricingOverride' is defined but never used @typescript-eslint/no-unused-vars
|
||||
33:10 warning 'CartSkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
51:10 warning 'OrdersSkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
239:10 warning 'products' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/wholesale/register/page.tsx
|
||||
35:9 warning 'router' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/AdminHeader.tsx
|
||||
55:49 warning 'canManageUsers' is defined but never used @typescript-eslint/no-unused-vars
|
||||
56:9 warning 'router' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/AdminOrdersPanel.tsx
|
||||
16:33 warning 'AdminCreateOrderItem' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/AdminSidebar.tsx
|
||||
239:5 error Compilation Skipped: Existing memoization could not be preserved
|
||||
|
||||
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output.
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/AdminSidebar.tsx:239:5
|
||||
237 | // Keyboard navigation for nav items (arrow up/down loops, enter/space activates)
|
||||
238 | const handleNavKeyDown = useCallback(
|
||||
> 239 | (e: KeyboardEvent<HTMLAnchorElement>, href: string, index: number) => {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 240 | const total = visibleItems.length;
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 241 | if (total === 0) return;
|
||||
…
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 261 | }
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 262 | },
|
||||
| ^^^^^^ Could not preserve existing memoization
|
||||
263 | [router, mobileOpen, closeMobileMenu, visibleItems.length],
|
||||
264 | );
|
||||
265 | react-hooks/preserve-manual-memoization
|
||||
263:43 error Compilation Skipped: Existing memoization could not be preserved
|
||||
|
||||
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This dependency may be mutated later, which could cause the value to change unexpectedly.
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/AdminSidebar.tsx:263:43
|
||||
261 | }
|
||||
262 | },
|
||||
> 263 | [router, mobileOpen, closeMobileMenu, visibleItems.length],
|
||||
| ^^^^^^^^^^^^ This dependency may be modified later
|
||||
264 | );
|
||||
265 |
|
||||
266 | async function handleLogout() { react-hooks/preserve-manual-memoization
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/AdminStopsPanel.tsx
|
||||
55:10 warning 'showImport' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/AdvancedIntegrations.tsx
|
||||
58:57 warning 'brands' is defined but never used @typescript-eslint/no-unused-vars
|
||||
59:27 warning 'setSelectedBrandId' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/AdvancedPayments.tsx
|
||||
119:18 warning 'refreshStatus' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/AdvancedShipping.tsx
|
||||
46:44 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/AdvancedSquareSync.tsx
|
||||
32:46 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/CampaignListPanel.tsx
|
||||
10:26 warning 'deleteCampaign' is defined but never used @typescript-eslint/no-unused-vars
|
||||
11:10 warning 'getCommunicationTemplates' is defined but never used @typescript-eslint/no-unused-vars
|
||||
202:21 warning 'setCampaigns' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/CardList.tsx
|
||||
23:57 warning 'as' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/CommandPalette.tsx
|
||||
190:5 error Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/CommandPalette.tsx:190:5
|
||||
188 | return;
|
||||
189 | }
|
||||
> 190 | setQuery("");
|
||||
| ^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
191 | setSelected(0);
|
||||
192 | document.body.style.overflow = "hidden";
|
||||
193 | // Defer focus to next frame so the input is mounted and the modal react-hooks/set-state-in-effect
|
||||
220:5 error Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/CommandPalette.tsx:220:5
|
||||
218 | // Reset selection on every query change so the top result is always active.
|
||||
219 | useEffect(() => {
|
||||
> 220 | setSelected(0);
|
||||
| ^^^^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
221 | }, [query]);
|
||||
222 |
|
||||
223 | // Clamp selection to results length react-hooks/set-state-in-effect
|
||||
226:7 error Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/CommandPalette.tsx:226:7
|
||||
224 | useEffect(() => {
|
||||
225 | if (selected >= results.length && results.length > 0) {
|
||||
> 226 | setSelected(results.length - 1);
|
||||
| ^^^^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
227 | } else if (results.length === 0) {
|
||||
228 | setSelected(0);
|
||||
229 | } react-hooks/set-state-in-effect
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ContactImportForm.tsx
|
||||
76:10 warning 'getStatColor' is defined but never used @typescript-eslint/no-unused-vars
|
||||
82:10 warning 'getStatValueColor' is defined but never used @typescript-eslint/no-unused-vars
|
||||
95:10 warning 'importing' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
101:10 warning 'useBucket' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
176:20 warning 'totalRows' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
219:11 warning 'emailColIdx' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
220:11 warning 'phoneColIdx' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
248:19 warning 'stripped' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/CreateUserModal.tsx
|
||||
5:10 warning 'AdminInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
5:22 warning 'AdminTextInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/DashboardClient.tsx
|
||||
3:25 warning 'ReactNode' is defined but never used @typescript-eslint/no-unused-vars
|
||||
7:78 warning 'Calendar' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/HarvestReach/AnalyticsDashboard.tsx
|
||||
109:10 warning 'EngagementBadge' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/HarvestReach/CampaignComposerPage.tsx
|
||||
6:24 warning 'SegmentRuleV2' is defined but never used @typescript-eslint/no-unused-vars
|
||||
147:15 warning 'isUpcoming' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
361:16 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/HarvestReach/MatchingCustomersPanel.tsx
|
||||
50:10 warning 'customerCount' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/HarvestReach/SegmentBuilderPage.tsx
|
||||
163:9 warning 'hasFilters' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/IntegrationsInner.tsx
|
||||
277:54 warning 'brands' is defined but never used @typescript-eslint/no-unused-vars
|
||||
278:27 warning 'setSelectedBrandId' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/LoadingState.tsx
|
||||
48:11 warning Unused eslint-disable directive (no problems were reported from 'react/no-array-index-key')
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/MessageLogPanel.tsx
|
||||
242:6 warning React Hook useEffect has a missing dependency: 'fetchLogs'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/NewStopForm.tsx
|
||||
7:10 warning 'AdminInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
7:22 warning 'AdminTextInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
7:38 warning 'AdminSelect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/OfflineBanner.tsx
|
||||
12:5 error Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/OfflineBanner.tsx:12:5
|
||||
10 |
|
||||
11 | useEffect(() => {
|
||||
> 12 | setMounted(true);
|
||||
| ^^^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
13 | setOnline(navigator.onLine);
|
||||
14 | const onOnline = () => setOnline(true);
|
||||
15 | const onOffline = () => setOnline(false); react-hooks/set-state-in-effect
|
||||
46:14 error `'` can be escaped with `'`, `‘`, `'`, `’` react/no-unescaped-entities
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/OrderEditForm.tsx
|
||||
62:10 warning 'saved' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
89:9 warning 'total' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
192:14 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/OrderPaymentSection.tsx
|
||||
8:33 warning 'AdminInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/PaymentSettingsForm.tsx
|
||||
5:58 warning 'PaymentSettings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
39:32 warning 'setStripePublishableKey' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
42:27 warning 'setStripeSecretKey' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
45:29 warning 'setSquareAccessToken' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
58:10 warning 'showSecretStripe' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
58:28 warning 'setShowSecretStripe' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
59:10 warning 'showSecretSquare' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
59:28 warning 'setShowSecretSquare' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ProductEditForm.tsx
|
||||
48:20 warning 'setDragOver' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ProductFilterBar.tsx
|
||||
3:10 warning 'useState' is defined but never used @typescript-eslint/no-unused-vars
|
||||
16:3 warning 'products' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/PullToRefresh.tsx
|
||||
38:5 error Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/PullToRefresh.tsx:38:5
|
||||
36 | useEffect(() => {
|
||||
37 | const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
> 38 | setPrefersReducedMotion(mq.matches);
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
39 | const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
|
||||
40 | mq.addEventListener("change", handler);
|
||||
41 | return () => mq.removeEventListener("change", handler); react-hooks/set-state-in-effect
|
||||
93:21 error Error: Cannot access refs during render
|
||||
|
||||
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/PullToRefresh.tsx:93:21
|
||||
91 | style={{
|
||||
92 | transform: `translateY(${offset}px)`,
|
||||
> 93 | transition: pulling.current || prefersReducedMotion ? "none" : "transform 200ms var(--ease-apple)",
|
||||
| ^^^^^^^^^^^^^^^ Cannot access ref value during render
|
||||
94 | overscrollBehavior: "contain",
|
||||
95 | position: "relative",
|
||||
96 | }} react-hooks/refs
|
||||
93:21 error Error: Cannot access refs during render
|
||||
|
||||
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/PullToRefresh.tsx:93:21
|
||||
91 | style={{
|
||||
92 | transform: `translateY(${offset}px)`,
|
||||
> 93 | transition: pulling.current || prefersReducedMotion ? "none" : "transform 200ms var(--ease-apple)",
|
||||
| ^^^^^^^^^^^^^^^ Cannot access ref value during render
|
||||
94 | overscrollBehavior: "contain",
|
||||
95 | position: "relative",
|
||||
96 | }} react-hooks/refs
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ReportsDashboard.tsx
|
||||
103:3 warning 'label' is defined but never used @typescript-eslint/no-unused-vars
|
||||
224:11 warning 'brandId' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ScheduleImportModal.tsx
|
||||
3:41 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/SettingsSections.tsx
|
||||
24:10 warning 'AdminInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
24:22 warning 'AdminTextInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
24:38 warning 'AdminSelect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
88:10 warning 'settings' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
89:10 warning 'notificationLog' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/SquareSyncWidget.tsx
|
||||
24:10 warning 'queueCount' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/StopEditForm.tsx
|
||||
6:22 warning 'AdminTextInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
39:10 warning 'saved' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/StopMessagingForm.tsx
|
||||
29:10 warning 'customMessage' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/StopProductAssignment.tsx
|
||||
47:3 warning 'callerUid' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/StopTableClient.tsx
|
||||
4:75 warning 'useRef' is defined but never used @typescript-eslint/no-unused-vars
|
||||
12:3 warning 'AdminIconButton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
259:9 warning 'SortIcon' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
608:3 warning 'isLoading' is defined but never used @typescript-eslint/no-unused-vars
|
||||
705:3 warning 'onDelete' is defined but never used @typescript-eslint/no-unused-vars
|
||||
977:40 warning 'showError' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/StopsHeaderActions.tsx
|
||||
22:33 warning 'count' is defined but never used @typescript-eslint/no-unused-vars
|
||||
26:29 warning 'stopId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/TaxDashboard.tsx
|
||||
4:10 warning 'formatDate' is defined but never used @typescript-eslint/no-unused-vars
|
||||
46:38 warning 'end' is defined but never used @typescript-eslint/no-unused-vars
|
||||
77:22 warning 'label' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/TemplateEditForm.tsx
|
||||
3:20 warning 'useCallback' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/TimeTrackingAdminPanel.tsx
|
||||
127:10 warning 'formatHours' is defined but never used @typescript-eslint/no-unused-vars
|
||||
531:47 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
531:56 warning 'onSave' is defined but never used @typescript-eslint/no-unused-vars
|
||||
582:43 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
582:52 warning 'onSave' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/TimeTrackingSettingsClient.tsx
|
||||
86:10 warning 'settings' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
132:23 warning 'setExportBrand' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
133:27 warning 'setIncludeOvertime' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
134:24 warning 'setIncludeNotes' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ToastContainer.tsx
|
||||
4:10 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/UpgradePlanModal.tsx
|
||||
217:19 warning 'canUpgrade' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/UsersPage.tsx
|
||||
4:24 warning 'UpdateAdminUserInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
126:10 warning 'resetLinkLoading' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminActionMenu.tsx
|
||||
19:52 warning 'triggerLabel' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/landing/LandingPageWrapper.tsx
|
||||
310:11 warning 'SectionProps' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/landing/TestimonialsAndCTA.tsx
|
||||
46:7 warning 'cardHoverVariants' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/notifications/InAppNotificationCenter.tsx
|
||||
21:55 warning 'userId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/notifications/ToastNotification.tsx
|
||||
73:9 warning 'colors' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/onboarding/OnboardingFlow.tsx
|
||||
90:43 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
90:61 warning '_userId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/AdminLookupPage.tsx
|
||||
9:10 warning 'getAgeStatus' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/FsmaReportModal.tsx
|
||||
184:6 warning React Hook useEffect has a missing dependency: 'fetchComplianceData'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/LotDetailPanel.tsx
|
||||
206:10 warning 'CheckIcon' is defined but never used @typescript-eslint/no-unused-vars
|
||||
216:3 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/LotListTable.tsx
|
||||
91:3 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/PublicTraceActions.tsx
|
||||
3:10 warning 'useState' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/QRScanModal.tsx
|
||||
179:6 warning React Hook useEffect has a missing dependency: 'onScanResult'. Either include it or remove the dependency array. If 'onScanResult' changes too often, find the parent component that defines it and wrap that definition in useCallback react-hooks/exhaustive-deps
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/QuickNewLotModal.tsx
|
||||
3:35 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/RouteTraceDashboard.tsx
|
||||
498:3 warning 'recentLots' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/RouteTracePage.tsx
|
||||
4:8 warning 'Link' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/StickerPreviewModal.tsx
|
||||
68:14 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
|
||||
249:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/storefront/CinematicShowcase.tsx
|
||||
39:10 warning '_index' is defined but never used @typescript-eslint/no-unused-vars
|
||||
40:13 warning '_isActive' is defined but never used @typescript-eslint/no-unused-vars
|
||||
56:5 warning Unused eslint-disable directive (no problems were reported from '@typescript-eslint/no-unused-vars')
|
||||
112:3 warning 'brandName' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
159:13 warning 'productSwitchTrigger' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/storefront/ProductCard.tsx
|
||||
136:14 warning 'brandName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
137:3 warning 'brandAccent' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/storefront/StorefrontHeader.tsx
|
||||
55:3 warning 'isAdmin' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/storefront/StripeExpressCheckout.tsx
|
||||
156:5 warning React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps
|
||||
299:3 warning 'items' is defined but never used @typescript-eslint/no-unused-vars
|
||||
300:3 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
304:3 warning 'selectedStop' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/storefront/TuxedoVideoHero.tsx
|
||||
68:7 error Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/storefront/TuxedoVideoHero.tsx:68:7
|
||||
66 | const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
||||
67 | if (prefersReducedMotion) {
|
||||
> 68 | setIsVisible(true);
|
||||
| ^^^^^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
69 | return;
|
||||
70 | }
|
||||
71 | react-hooks/set-state-in-effect
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/time-tracking/TimeTrackingFieldClient.tsx
|
||||
21:7 warning 'BRAND_ID' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
22:7 warning 'BRAND_NAME' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
167:3 warning 'brandAccent' is defined but never used @typescript-eslint/no-unused-vars
|
||||
256:6 warning React Hook useEffect has a missing dependency: 'loadPayPeriod'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||
496:9 warning 'dailyRemaining' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/ui/ScrollAnimations.tsx
|
||||
197:10 warning '_speed' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
315:3 warning 'to' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/water/WaterAdminClient.tsx
|
||||
127:10 warning 'SummarySkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
272:10 warning 'loading' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/wholesale/admin/CustomerPricingPanel.tsx
|
||||
23:18 warning 'setSaving' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/wholesale/admin/DashboardTab.tsx
|
||||
3:15 warning 'MsgFn' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/wholesale/admin/OrdersTab.tsx
|
||||
46:10 warning 'deleting' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
60:52 warning Expected an assignment or function call and instead saw an expression @typescript-eslint/no-unused-expressions
|
||||
420:59 warning 'd' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/analytics.ts
|
||||
5:7 warning 'posthogHost' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
12:27 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
18:34 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
24:53 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
30:55 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
36:34 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
54:41 warning '_reason' is defined but never used @typescript-eslint/no-unused-vars
|
||||
84:51 warning '_resourceId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
102:47 warning '_context' is defined but never used @typescript-eslint/no-unused-vars
|
||||
108:57 warning '_category' is defined but never used @typescript-eslint/no-unused-vars
|
||||
121:30 warning '_userId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
121:47 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
127:30 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
127:48 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/billing.ts
|
||||
6:8 warning 'Stripe' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/offline/sync.ts
|
||||
2:10 warning 'enqueueAction' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/pwa.ts
|
||||
34:39 warning '_registration' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/rate-limit.ts
|
||||
5:6 warning 'Duration' is defined but never used @typescript-eslint/no-unused-vars
|
||||
39:9 warning 'windowStart' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/use-media-query.ts
|
||||
16:5 error Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/use-media-query.ts:16:5
|
||||
14 | const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
15 | mq.addEventListener("change", handler);
|
||||
> 16 | setMatches(mq.matches);
|
||||
| ^^^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
17 | return () => mq.removeEventListener("change", handler);
|
||||
18 | }, [query]);
|
||||
19 | react-hooks/set-state-in-effect
|
||||
|
||||
✖ 383 problems (14 errors, 369 warnings)
|
||||
0 errors and 6 warnings potentially fixable with the `--fix` option.
|
||||
|
||||
@@ -0,0 +1,732 @@
|
||||
|
||||
> route-commerce-platform@2.0.0 lint
|
||||
> eslint
|
||||
|
||||
|
||||
/home/tyler/dev/routecomm/db/schema/brands.ts
|
||||
12:3 warning 'index' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/db/schema/customers.ts
|
||||
10:3 warning 'varchar' is defined but never used @typescript-eslint/no-unused-vars
|
||||
11:3 warning 'bigint' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/db/schema/marketing.ts
|
||||
52:52 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
/home/tyler/dev/routecomm/db/schema/water-log.ts
|
||||
27:3 warning 'check' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/playwright.config.ts
|
||||
2:8 warning 'path' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/admin/password.ts
|
||||
17:16 warning 'updatePasswordAction' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/ai-import.ts
|
||||
126:3 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
254:9 warning 'keywordMaps' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/billing/stripe-checkout.ts
|
||||
68:9 warning 'recurring' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/communications/contacts.ts
|
||||
107:9 warning 'fullName' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
411:16 warning 'optOutContact' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/communications/import-contacts.ts
|
||||
5:21 warning 'withPlatformAdmin' is defined but never used @typescript-eslint/no-unused-vars
|
||||
10:3 warning 'previewContactImport' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/communications/segments.ts
|
||||
70:16 warning 'saveSegments' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/dashboard.ts
|
||||
240:16 warning 'getDashboardSummary' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/email-automation/abandoned-cart.ts
|
||||
254:16 warning 'markCartRecovered' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/email-automation/welcome-sequence.ts
|
||||
184:16 warning 'sendWelcomeEmail' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/harvest-reach/campaigns.ts
|
||||
78:16 warning 'getHarvestReachCampaigns' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/import-orders.ts
|
||||
5:10 warning 'orders' is defined but never used @typescript-eslint/no-unused-vars
|
||||
5:18 warning 'orderItems' is defined but never used @typescript-eslint/no-unused-vars
|
||||
5:30 warning 'customers' is defined but never used @typescript-eslint/no-unused-vars
|
||||
6:10 warning 'eq' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/integrations/ai-providers.ts
|
||||
199:16 warning 'getCustomIntegrations' is defined but never used @typescript-eslint/no-unused-vars
|
||||
212:16 warning 'upsertCustomIntegration' is defined but never used @typescript-eslint/no-unused-vars
|
||||
233:16 warning 'deleteCustomIntegration' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/integrations/credentials.ts
|
||||
178:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
210:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/orders.ts
|
||||
139:16 warning 'getAdminStops' is defined but never used @typescript-eslint/no-unused-vars
|
||||
146:16 warning 'getAdminPendingOrders' is defined but never used @typescript-eslint/no-unused-vars
|
||||
153:16 warning 'getAdminPickedUpOrders' is defined but never used @typescript-eslint/no-unused-vars
|
||||
184:16 warning 'toggleOrderPickupComplete' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/route-trace/lots.ts
|
||||
203:58 warning '_status' is defined but never used @typescript-eslint/no-unused-vars
|
||||
210:46 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
237:3 warning '_data' is defined but never used @typescript-eslint/no-unused-vars
|
||||
246:3 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
247:3 warning '_status' is defined but never used @typescript-eslint/no-unused-vars
|
||||
248:3 warning '_location' is defined but never used @typescript-eslint/no-unused-vars
|
||||
249:3 warning '_notes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
250:3 warning '_binId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
276:58 warning '_query' is defined but never used @typescript-eslint/no-unused-vars
|
||||
283:37 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
304:36 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
328:3 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
329:3 warning '_orderId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
330:3 warning '_quantityToAdd' is defined but never used @typescript-eslint/no-unused-vars
|
||||
331:3 warning '_notes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
341:3 warning '_limit' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
359:40 warning '_lotNumber' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/shipping.ts
|
||||
20:3 warning '_orderId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
21:3 warning '_status' is defined but never used @typescript-eslint/no-unused-vars
|
||||
22:3 warning '_trackingNumber' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/shipping/fedex-labels.ts
|
||||
20:43 warning 'clearFedExToken' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/shipping/fedex-rates.ts
|
||||
104:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/square-sync-ui.ts
|
||||
90:16 warning 'getSquareQueueCount' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/storefront.ts
|
||||
130:16 warning 'getStorefrontData' is defined but never used @typescript-eslint/no-unused-vars
|
||||
154:16 warning 'getStorefrontWholesaleSettings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
181:16 warning 'getWholesalePortalConfig' is defined but never used @typescript-eslint/no-unused-vars
|
||||
202:16 warning 'getStorefrontBrandName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/tax.ts
|
||||
112:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/time-tracking/field.ts
|
||||
32:7 warning 'COOKIE_MAX_AGE' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
34:10 warning 'sessionCookie' is defined but never used @typescript-eslint/no-unused-vars
|
||||
57:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
58:3 warning '_pin' is defined but never used @typescript-eslint/no-unused-vars
|
||||
71:3 warning '_taskName' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
83:3 warning '_lunchMinutes' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
84:3 warning '_notes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
96:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
146:3 warning '_activeOnly' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
187:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/time-tracking/index.ts
|
||||
67:46 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
76:3 warning '_role' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
77:3 warning '_lang' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
85:42 warning '_workerId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
93:3 warning '_workerId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
94:3 warning '_name' is defined but never used @typescript-eslint/no-unused-vars
|
||||
95:3 warning '_role' is defined but never used @typescript-eslint/no-unused-vars
|
||||
96:3 warning '_lang' is defined but never used @typescript-eslint/no-unused-vars
|
||||
97:3 warning '_active' is defined but never used @typescript-eslint/no-unused-vars
|
||||
105:40 warning '_workerId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
114:62 warning '_activeOnly' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
122:3 warning '_nameEs' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
123:3 warning '_unit' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
124:3 warning '_sortOrder' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
133:3 warning '_taskId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
134:3 warning '_name' is defined but never used @typescript-eslint/no-unused-vars
|
||||
135:3 warning '_nameEs' is defined but never used @typescript-eslint/no-unused-vars
|
||||
136:3 warning '_unit' is defined but never used @typescript-eslint/no-unused-vars
|
||||
137:3 warning '_active' is defined but never used @typescript-eslint/no-unused-vars
|
||||
138:3 warning '_sortOrder' is defined but never used @typescript-eslint/no-unused-vars
|
||||
146:38 warning '_taskId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
157:3 warning '_options' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
170:16 warning 'updateWorkerTimeLog' is defined but never used @typescript-eslint/no-unused-vars
|
||||
171:3 warning '_logId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
172:3 warning '_taskName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
173:3 warning '_clockIn' is defined but never used @typescript-eslint/no-unused-vars
|
||||
174:3 warning '_clockOut' is defined but never used @typescript-eslint/no-unused-vars
|
||||
175:3 warning '_lunchMinutes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
176:3 warning '_notes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
184:16 warning 'deleteWorkerTimeLog' is defined but never used @typescript-eslint/no-unused-vars
|
||||
184:36 warning '_logId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
192:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
193:3 warning '_start' is defined but never used @typescript-eslint/no-unused-vars
|
||||
194:3 warning '_end' is defined but never used @typescript-eslint/no-unused-vars
|
||||
221:47 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
228:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
229:3 warning '_settings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
271:3 warning '_limit' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/time-tracking/notifications.ts
|
||||
21:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
22:3 warning '_workerId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
23:3 warning '_workerName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
24:3 warning '_dailyHours' is defined but never used @typescript-eslint/no-unused-vars
|
||||
25:3 warning '_weeklyHours' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/water-log/admin.ts
|
||||
20:25 warning 'gte' is defined but never used @typescript-eslint/no-unused-vars
|
||||
20:30 warning 'lte' is defined but never used @typescript-eslint/no-unused-vars
|
||||
20:40 warning 'SQL' is defined but never used @typescript-eslint/no-unused-vars
|
||||
32:32 warning 'verifyPin' is defined but never used @typescript-eslint/no-unused-vars
|
||||
33:25 warning 'logAlert' is defined but never used @typescript-eslint/no-unused-vars
|
||||
128:16 warning 'requireWaterAdminSession' is defined but never used @typescript-eslint/no-unused-vars
|
||||
164:3 warning Unused eslint-disable directive (no problems were reported from 'no-var')
|
||||
202:10 warning 'mapEntry' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/water-log/field.ts
|
||||
22:15 warning 'desc' is defined but never used @typescript-eslint/no-unused-vars
|
||||
22:25 warning 'gte' is defined but never used @typescript-eslint/no-unused-vars
|
||||
22:30 warning 'sql' is defined but never used @typescript-eslint/no-unused-vars
|
||||
186:3 warning '_headgateLocked' is defined but never used @typescript-eslint/no-unused-vars
|
||||
325:16 warning 'getFieldSessionUser' is defined but never used @typescript-eslint/no-unused-vars
|
||||
388:16 warning 'getWaterSession' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/wholesale-auth.ts
|
||||
21:44 warning '_formData' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/wholesale-register.ts
|
||||
281:12 warning '_' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/wholesale.ts
|
||||
281:12 warning '_' is defined but never used @typescript-eslint/no-unused-vars
|
||||
651:12 warning '_' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/communications/abandoned-carts/page.tsx
|
||||
8:7 warning 'IRD_BRAND_ID' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/import/ImportCenterClient.tsx
|
||||
154:9 warning 'activeBrandName' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/launch-checklist/page.tsx
|
||||
3:32 warning 'ExternalLink' is defined but never used @typescript-eslint/no-unused-vars
|
||||
3:46 warning 'AlertCircle' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/me/page.tsx
|
||||
5:10 warning 'redirect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/sales/import/page.tsx
|
||||
76:9 warning 'SAMPLE_CSV' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/settings/billing/AddPaymentMethodButton.tsx
|
||||
4:10 warning 'createAddonCheckoutSession' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/settings/integrations/page.tsx
|
||||
2:10 warning 'redirect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/stops/[id]/page.tsx
|
||||
49:11 warning 'ProductStop' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/stops/new/page.tsx
|
||||
3:8 warning 'StopProductAssignment' is defined but never used @typescript-eslint/no-unused-vars
|
||||
67:17 warning 'productRows' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/stops/page.tsx
|
||||
21:10 warning 'params' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/water-log/headgates/page.tsx
|
||||
3:34 warning 'regenerateHeadgateToken' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/water-log/page.tsx
|
||||
12:10 warning 'WaterLogLoadingSkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/wholesale/DashboardTab.tsx
|
||||
17:12 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
18:10 warning '_onMsg' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/campaign-writer/route.ts
|
||||
61:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/customer-insights/route.ts
|
||||
140:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/demand-forecast/route.ts
|
||||
79:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/pricing-advisor/route.ts
|
||||
79:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/product-writer/route.ts
|
||||
74:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/report-explainer/route.ts
|
||||
84:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/route-optimizer/route.ts
|
||||
83:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/stop-blast-advisor/route.ts
|
||||
15:22 warning 'stopId' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
85:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/auth/sign-in/route.ts
|
||||
13:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/square/oauth/complete/route.ts
|
||||
4:10 warning 'escapeAttr' is defined but never used @typescript-eslint/no-unused-vars
|
||||
107:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/square/oauth/route.ts
|
||||
2:10 warning 'cookies' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/square/process-queue/route.ts
|
||||
61:9 warning 'lastError' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/stops/import/route.ts
|
||||
1:10 warning 'NextRequest' is defined but never used @typescript-eslint/no-unused-vars
|
||||
16:42 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/stripe/oauth/complete/route.ts
|
||||
89:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/time-tracking/export/route.ts
|
||||
75:9 warning 'includeNotes' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
127:11 warning 'weeklyThreshold' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
141:36 warning 'i' is defined but never used @typescript-eslint/no-unused-vars
|
||||
142:13 warning 'worker' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
144:15 warning 'regularHours' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
255:15 warning 'regularHours' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/time-tracking/notify/route.ts
|
||||
7:7 warning 'IRD_BRAND_ID' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/tuxedo/schedule-pdf/route.ts
|
||||
5:6 warning 'Settings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/water-photo-upload/route.ts
|
||||
16:11 warning 'bucket' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
43:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/water-qr-label/route.ts
|
||||
1:10 warning 'NextResponse' is defined but never used @typescript-eslint/no-unused-vars
|
||||
9:35 warning 'token' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/water-qr-sheet/route.ts
|
||||
1:10 warning 'NextResponse' is defined but never used @typescript-eslint/no-unused-vars
|
||||
8:35 warning 'token' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/water-qr/route.ts
|
||||
1:10 warning 'NextResponse' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/wholesale/invoice/[orderId]/route.ts
|
||||
147:12 warning 'rightAlign' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/wholesale/manifest/route.ts
|
||||
27:9 warning 'effectiveBrandId' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
37:9 warning 'rows' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/change-password/page.tsx
|
||||
6:10 warning 'getCurrentUserId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/error.tsx
|
||||
21:5 warning Unused eslint-disable directive (no problems were reported from 'no-console')
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/indian-river-direct/contact/layout.tsx
|
||||
42:52 warning 'children' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/indian-river-direct/faq/layout.tsx
|
||||
83:54 warning 'children' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/indian-river-direct/stops/[id]/page.tsx
|
||||
8:8 warning 'StopSetEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
51:21 warning 'setBrandSlug' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
52:23 warning 'setBrandAccent' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/page.tsx
|
||||
1:25 warning 'Viewport' is defined but never used @typescript-eslint/no-unused-vars
|
||||
123:9 warning Unused eslint-disable directive (no problems were reported from 'react/no-danger')
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/roadmap/page.tsx
|
||||
64:7 warning 'CATEGORIES' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/trace/[lotNumber]/page.tsx
|
||||
83:9 warning 'progressPercent' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/about/AboutClient.tsx
|
||||
3:26 warning 'useState' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/contact/ContactClientPage.tsx
|
||||
4:8 warning 'Link' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/contact/layout.tsx
|
||||
42:47 warning 'children' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/faq/layout.tsx
|
||||
132:43 warning 'children' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/page.tsx
|
||||
23:10 warning 'scrollToProducts' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/stops/[id]/page.tsx
|
||||
7:8 warning 'StopSetEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
50:21 warning 'setBrandSlug' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
51:23 warning 'setBrandAccent' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/water/admin/login/page.tsx
|
||||
1:10 warning 'redirect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/wholesale/employee/EmployeePortalClient.tsx
|
||||
435:10 warning 'EmployeePortalSkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/wholesale/portal/page.tsx
|
||||
94:5 error 'userId' is never reassigned. Use 'const' instead prefer-const
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/wholesale/register/page.tsx
|
||||
268:9 warning 'router' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/auth.config.ts
|
||||
66:10 warning 'getNeonAuthConfigOptional' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/AnalyticsDashboard.tsx
|
||||
82:30 warning 'i' is defined but never used @typescript-eslint/no-unused-vars
|
||||
142:37 warning 'i' is defined but never used @typescript-eslint/no-unused-vars
|
||||
197:35 warning 'i' is defined but never used @typescript-eslint/no-unused-vars
|
||||
287:28 warning 'i' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/BrandSettingsForm.tsx
|
||||
43:13 warning '_initialSettings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
47:20 warning '_isPlatformAdmin' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/CampaignListPanel.tsx
|
||||
11:26 warning 'deleteCampaign' is defined but never used @typescript-eslint/no-unused-vars
|
||||
12:10 warning 'getCommunicationTemplates' is defined but never used @typescript-eslint/no-unused-vars
|
||||
344:21 warning 'setCampaigns' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/CardList.tsx
|
||||
23:57 warning 'as' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ContactImportForm.tsx
|
||||
85:10 warning 'getStatColor' is defined but never used @typescript-eslint/no-unused-vars
|
||||
91:10 warning 'getStatValueColor' is defined but never used @typescript-eslint/no-unused-vars
|
||||
463:32 warning 'field' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/EditStopModal.tsx
|
||||
608:10 warning 'useEditStopModal' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/HarvestReach/AnalyticsDashboard.tsx
|
||||
109:10 warning 'EngagementBadge' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/HarvestReach/CampaignComposerPage.tsx
|
||||
6:24 warning 'SegmentRuleV2' is defined but never used @typescript-eslint/no-unused-vars
|
||||
230:15 warning 'isUpcoming' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/HarvestReach/MatchingCustomersPanel.tsx
|
||||
2:1 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/LoadingState.tsx
|
||||
48:11 warning Unused eslint-disable directive (no problems were reported from 'react/no-array-index-key')
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/NewStopForm.tsx
|
||||
7:10 warning 'AdminInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
7:23 warning 'AdminTextInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
7:39 warning 'AdminSelect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/OrderEditForm.tsx
|
||||
175:9 warning 'total' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ReportsDashboard.tsx
|
||||
337:11 warning 'brandId' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/SettingsClient.tsx
|
||||
2:1 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
|
||||
4:20 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/SettingsSections.tsx
|
||||
21:10 warning 'AdminInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
21:23 warning 'AdminTextInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
21:39 warning 'AdminSelect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ShippingSettingsForm.tsx
|
||||
160:13 warning '_initialSettings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
163:20 warning '_isPlatformAdmin' is defined but never used @typescript-eslint/no-unused-vars
|
||||
180:25 error Error: Cannot access refs during render
|
||||
|
||||
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ShippingSettingsForm.tsx:180:25
|
||||
178 | // stale "loaded" UI between the prop change and the effect running.
|
||||
179 | const lastFetchedBrandIdRef = useRef<string | null>(null);
|
||||
> 180 | if (activeBrandId !== lastFetchedBrandIdRef.current) {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Cannot access ref value during render
|
||||
181 | lastFetchedBrandIdRef.current = activeBrandId;
|
||||
182 | setLoadData({ settings: null, loading: true });
|
||||
183 | } react-hooks/refs
|
||||
181:5 error Error: Cannot access refs during render
|
||||
|
||||
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ShippingSettingsForm.tsx:181:5
|
||||
179 | const lastFetchedBrandIdRef = useRef<string | null>(null);
|
||||
180 | if (activeBrandId !== lastFetchedBrandIdRef.current) {
|
||||
> 181 | lastFetchedBrandIdRef.current = activeBrandId;
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Cannot update ref during render
|
||||
182 | setLoadData({ settings: null, loading: true });
|
||||
183 | }
|
||||
184 | react-hooks/refs
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/StopProductAssignment.tsx
|
||||
89:3 warning 'callerUid' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/StopTableClient.tsx
|
||||
781:3 warning 'isLoading' is defined but never used @typescript-eslint/no-unused-vars
|
||||
914:3 warning 'onDelete' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/TimeTrackingAdminPanel.tsx
|
||||
127:10 warning 'formatHours' is defined but never used @typescript-eslint/no-unused-vars
|
||||
707:47 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
707:56 warning 'onSave' is defined but never used @typescript-eslint/no-unused-vars
|
||||
760:43 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
760:52 warning 'onSave' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/TimeTrackingSettingsClient.tsx
|
||||
2:1 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/Toast.tsx
|
||||
88:10 warning 'useToastActions' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ToastContainer.tsx
|
||||
4:10 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
122:10 warning 'InlineToast' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/UpgradePlanModal.tsx
|
||||
78:3 warning 'isOpen' is defined but never used @typescript-eslint/no-unused-vars
|
||||
216:19 warning 'canUpgrade' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/UsersPage.tsx
|
||||
4:24 warning 'UpdateAdminUserInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/command-palette-data.ts
|
||||
389:7 warning 'PALETTE_ICON_NAMES' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminActionMenu.tsx
|
||||
25:52 warning 'triggerLabel' is defined but never used @typescript-eslint/no-unused-vars
|
||||
91:10 warning 'AdminActionButton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminCard.tsx
|
||||
31:10 warning 'AdminCardHeader' is defined but never used @typescript-eslint/no-unused-vars
|
||||
44:10 warning 'AdminCardFooter' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminDeleteConfirm.tsx
|
||||
73:10 warning 'useDeleteConfirm' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminFilterTabs.tsx
|
||||
141:10 warning 'AdminStatusFilterTabs' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminFormElements.tsx
|
||||
170:10 warning 'AdminCheckbox' is defined but never used @typescript-eslint/no-unused-vars
|
||||
210:10 warning 'AdminLoadingOverlay' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminPagination.tsx
|
||||
89:10 warning 'AdminSimplePagination' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminStatsBar.tsx
|
||||
23:25 warning 'i' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminTable.tsx
|
||||
83:10 warning 'TableStatusBadge' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminToggle.tsx
|
||||
71:10 warning 'AdminToggleCompact' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/Skeleton.tsx
|
||||
36:10 warning 'SkeletonTable' is defined but never used @typescript-eslint/no-unused-vars
|
||||
51:10 warning 'SkeletonCard' is defined but never used @typescript-eslint/no-unused-vars
|
||||
68:10 warning 'SkeletonStats' is defined but never used @typescript-eslint/no-unused-vars
|
||||
82:10 warning 'PageSkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
150:10 warning 'FormSkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/landing/LandingPageWrapper.tsx
|
||||
281:11 warning 'SectionProps' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/notifications/ToastNotification.tsx
|
||||
1:1 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/notifications/toast-store.ts
|
||||
17:10 warning 'emit' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/AdminLookupPage.tsx
|
||||
9:10 warning 'getAgeStatus' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/LotDetailPanel.tsx
|
||||
206:10 warning 'CheckIcon' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/LotListTable.tsx
|
||||
91:3 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/RouteTraceDashboard.tsx
|
||||
499:3 warning 'recentLots' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/RouteTracePage.tsx
|
||||
4:8 warning 'Link' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/StickerPreviewModal.tsx
|
||||
111:14 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/storefront/CinematicShowcase.tsx
|
||||
39:10 warning '_index' is defined but never used @typescript-eslint/no-unused-vars
|
||||
40:13 warning '_isActive' is defined but never used @typescript-eslint/no-unused-vars
|
||||
56:5 warning Unused eslint-disable directive (no problems were reported from '@typescript-eslint/no-unused-vars')
|
||||
113:3 warning 'brandName' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
160:13 warning 'productSwitchTrigger' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/storefront/ProductCard.tsx
|
||||
136:14 warning 'brandName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
137:3 warning 'brandAccent' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/storefront/StorefrontHeader.tsx
|
||||
55:3 warning 'isAdmin' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/time-tracking/TimeTrackingFieldClient.tsx
|
||||
20:7 warning 'BRAND_ID' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
21:7 warning 'BRAND_NAME' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
509:3 warning 'brandAccent' is defined but never used @typescript-eslint/no-unused-vars
|
||||
678:9 warning 'dailyRemaining' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/ui/ScrollAnimations.tsx
|
||||
2:1 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
|
||||
140:10 warning '_speed' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
192:10 warning 'ProgressIndicator' is defined but never used @typescript-eslint/no-unused-vars
|
||||
258:3 warning 'to' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/water/WaterAdminClient.tsx
|
||||
261:10 warning 'SummarySkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/wholesale/DepositModal.tsx
|
||||
46:9 warning 'handleBackdropClick' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/wholesale/OrderDetailsModal.tsx
|
||||
58:9 warning 'handleBackdropClick' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/ai-provider-models.ts
|
||||
40:10 warning 'getModelsForProvider' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/analytics.ts
|
||||
5:7 warning 'posthogHost' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
12:27 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
18:34 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
24:53 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
30:55 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
36:34 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
54:41 warning '_reason' is defined but never used @typescript-eslint/no-unused-vars
|
||||
84:51 warning '_resourceId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
102:47 warning '_context' is defined but never used @typescript-eslint/no-unused-vars
|
||||
108:57 warning '_category' is defined but never used @typescript-eslint/no-unused-vars
|
||||
121:10 warning 'identifyUser' is defined but never used @typescript-eslint/no-unused-vars
|
||||
121:23 warning '_userId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
121:40 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
127:10 warning 'groupByBrand' is defined but never used @typescript-eslint/no-unused-vars
|
||||
127:23 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
127:41 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/date-utils.ts
|
||||
17:10 warning 'formatDateTime' is defined but never used @typescript-eslint/no-unused-vars
|
||||
29:10 warning 'formatDateISO' is defined but never used @typescript-eslint/no-unused-vars
|
||||
39:10 warning 'formatDateTimeLocal' is defined but never used @typescript-eslint/no-unused-vars
|
||||
49:10 warning 'timeAgo' is defined but never used @typescript-eslint/no-unused-vars
|
||||
69:10 warning 'getMonthName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
76:10 warning 'getMonthNameShort' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/feature-flags.ts
|
||||
117:7 warning 'CORE_FEATURES' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
200:10 warning 'invalidateBrandFeatureCache' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/format-date.ts
|
||||
31:10 warning 'formatTime' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/offline/sync.ts
|
||||
2:10 warning 'enqueueAction' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/pricing.ts
|
||||
134:7 warning 'TIER_FEATURE_MATRIX' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
171:10 warning 'formatPrice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
176:10 warning 'formatPricePerMonth' is defined but never used @typescript-eslint/no-unused-vars
|
||||
181:10 warning 'getPlanMonthlyPrice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
185:10 warning 'getPlanAnnualPrice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
189:10 warning 'getAddonMonthlyPrice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
193:10 warning 'getAddonAnnualPrice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
197:10 warning 'calculateAnnualSavings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
201:7 warning 'BILLING_COMPANY_NAME' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
202:7 warning 'BILLING_EMAIL' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/pwa.ts
|
||||
34:16 warning 'subscribeToPush' is defined but never used @typescript-eslint/no-unused-vars
|
||||
34:32 warning '_registration' is defined but never used @typescript-eslint/no-unused-vars
|
||||
40:10 warning 'isPWAInstalled' is defined but never used @typescript-eslint/no-unused-vars
|
||||
49:16 warning 'shareContent' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/rate-limit.ts
|
||||
5:6 warning 'Duration' is defined but never used @typescript-eslint/no-unused-vars
|
||||
39:9 warning 'windowStart' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
79:10 warning 'rateLimitHeaders' is defined but never used @typescript-eslint/no-unused-vars
|
||||
89:7 warning 'checkoutLimiter' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
91:7 warning 'authLimiter' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/sentry.ts
|
||||
56:7 warning 'addBreadcrumb' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
65:7 warning 'setUserContext' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
73:16 warning 'withTransaction' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/server-log.ts
|
||||
20:10 warning 'serverInfo' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/storage.ts
|
||||
84:16 warning 'deleteObject' is defined but never used @typescript-eslint/no-unused-vars
|
||||
101:16 warning 'getPresignedPutUrl' is defined but never used @typescript-eslint/no-unused-vars
|
||||
121:16 warning 'getPresignedGetUrl' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/stripe-billing.ts
|
||||
36:7 warning 'PLANS' is assigned a value but only used as a type @typescript-eslint/no-unused-vars
|
||||
171:16 warning 'createSubscription' is defined but never used @typescript-eslint/no-unused-vars
|
||||
215:16 warning 'createAddonSubscription' is defined but never used @typescript-eslint/no-unused-vars
|
||||
259:16 warning 'createCustomerPortalSession' is defined but never used @typescript-eslint/no-unused-vars
|
||||
277:16 warning 'cancelSubscription' is defined but never used @typescript-eslint/no-unused-vars
|
||||
287:16 warning 'reactivateSubscription' is defined but never used @typescript-eslint/no-unused-vars
|
||||
293:16 warning 'changePlan' is defined but never used @typescript-eslint/no-unused-vars
|
||||
340:16 warning 'getCustomer' is defined but never used @typescript-eslint/no-unused-vars
|
||||
344:16 warning 'updateCustomer' is defined but never used @typescript-eslint/no-unused-vars
|
||||
352:16 warning 'listPaymentMethods' is defined but never used @typescript-eslint/no-unused-vars
|
||||
359:16 warning 'attachPaymentMethod' is defined but never used @typescript-eslint/no-unused-vars
|
||||
365:16 warning 'setDefaultPaymentMethod' is defined but never used @typescript-eslint/no-unused-vars
|
||||
377:16 warning 'listInvoices' is defined but never used @typescript-eslint/no-unused-vars
|
||||
384:16 warning 'getInvoice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
388:16 warning 'getUpcomingInvoice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
399:16 warning 'createPaymentIntent' is defined but never used @typescript-eslint/no-unused-vars
|
||||
416:16 warning 'createRefund' is defined but never used @typescript-eslint/no-unused-vars
|
||||
616:16 warning 'createUsageRecord' is defined but never used @typescript-eslint/no-unused-vars
|
||||
643:16 warning 'getUsageSummary' is defined but never used @typescript-eslint/no-unused-vars
|
||||
656:16 warning 'getSubscription' is defined but never used @typescript-eslint/no-unused-vars
|
||||
660:16 warning 'listSubscriptions' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/supabase.ts
|
||||
63:33 warning '_opts' is defined but never used @typescript-eslint/no-unused-vars
|
||||
150:5 warning '_onrejected' is defined but never used @typescript-eslint/no-unused-vars
|
||||
185:13 warning 'likeNeedle' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
188:13 warning 'ilikeNeedle' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
307:6 warning '_column' is defined but never used @typescript-eslint/no-unused-vars
|
||||
307:23 warning '_value' is defined but never used @typescript-eslint/no-unused-vars
|
||||
310:6 warning '_column' is defined but never used @typescript-eslint/no-unused-vars
|
||||
310:23 warning '_values' is defined but never used @typescript-eslint/no-unused-vars
|
||||
319:8 warning '_bucket' is defined but never used @typescript-eslint/no-unused-vars
|
||||
321:37 warning '_file' is defined but never used @typescript-eslint/no-unused-vars
|
||||
322:24 warning '_path' is defined but never used @typescript-eslint/no-unused-vars
|
||||
379:34 warning '_creds' is defined but never used @typescript-eslint/no-unused-vars
|
||||
384:26 warning '_attrs' is defined but never used @typescript-eslint/no-unused-vars
|
||||
392:17 warning '_name' is defined but never used @typescript-eslint/no-unused-vars
|
||||
392:32 warning '_params' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/water-log-reporting.ts
|
||||
38:7 warning 'WATER_LOG_DISPLAY_REFRESH_MS' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
✖ 390 problems (3 errors, 387 warnings)
|
||||
0 errors and 10 warnings potentially fixable with the `--fix` option.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
React Doctor v0.5.8
|
||||
|
||||
✔ Select projects › route-commerce-platform
|
||||
|
||||
✔ Scanned 620 files in 19.4s [~20 workers]
|
||||
No issues found!
|
||||
|
||||
┌─────┐ 100 / 100 Great
|
||||
│ ◠ ◠ │ [38;2;201;113;61m█[39m[38;2;199;115;54m█[39m[38;2;196;118;47m█[39m[38;2;194;120;40m█[39m[38;2;190;122;33m█[39m[38;2;187;125;26m█[39m[38;2;183;127;19m█[39m[38;2;179;130;13m█[39m[38;2;175;132;8m█[39m[38;2;170;135;6m█[39m[38;2;165;137;9m█[39m[38;2;159;140;14m█[39m[38;2;154;142;21m█[39m[38;2;148;145;28m█[39m[38;2;141;147;36m█[39m[38;2;134;149;43m█[39m[38;2;127;151;50m█[39m[38;2;120;153;58m█[39m[38;2;112;155;65m█[39m[38;2;104;157;72m█[39m[38;2;95;158;79m█[39m[38;2;86;159;87m█[39m[38;2;76;161;94m█[39m[38;2;65;162;101m█[39m[38;2;53;163;108m█[39m[38;2;38;163;115m█[39m[38;2;15;164;122m█[39m[38;2;0;164;129m█[39m[38;2;0;164;136m█[39m[38;2;0;164;142m█[39m[38;2;0;164;149m█[39m[38;2;0;164;155m█[39m[38;2;0;163;162m█[39m[38;2;0;162;168m█[39m[38;2;0;161;173m█[39m[38;2;0;160;179m█[39m[38;2;0;159;184m█[39m[38;2;0;158;189m█[39m[38;2;0;156;194m█[39m[38;2;0;155;198m█[39m[38;2;0;153;202m█[39m[38;2;22;151;205m█[39m[38;2;41;149;208m█[39m[38;2;54;147;211m█[39m[38;2;66;145;213m█[39m[38;2;76;143;215m█[39m[38;2;85;141;216m█[39m[38;2;94;138;217m█[39m[38;2;102;136;218m█[39m[38;2;109;134;218m█[39m
|
||||
│ ▽ │ React Doctor (https://react.doctor)
|
||||
└─────┘
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
React Doctor v0.5.8
|
||||
|
||||
✔ Select projects › route-commerce-platform
|
||||
|
||||
✔ Scanned 620 files in 22.3s [~20 workers]
|
||||
|
||||
⚠ Bugs: Plain anchor reloads internal Next.js links
|
||||
Learn more: https://react.doctor/docs/rules/react-doctor/nextjs-no-a-element
|
||||
Plain <a> reloads the whole page for internal links, so
|
||||
Next.js loses client-side navigation and prefetching.
|
||||
→ `import Link from 'next/link'` for client-side
|
||||
navigation, prefetching, and preserved scroll position
|
||||
|
||||
src/app/pricing/PricingClientPage.tsx:422
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
|
||||
All 1 issue
|
||||
|
||||
Bugs › 1 warning
|
||||
|
||||
┌─────┐ 92 / 100 Great
|
||||
│ ◠ ◠ │ ██████████████████████████████████████████████░░░░
|
||||
│ ▽ │ React Doctor (https://react.doctor)
|
||||
└─────┘
|
||||
|
||||
Full diagnostics written to /tmp/react-doctor-7eb39635-105b-4770-a1df-00bffa4abaa5
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
|
||||
Share: https://react.doctor/share?p=route-commerce-platform&s=92&w=1&f=1
|
||||
Tell others how you did on socials
|
||||
|
||||
Docs: https://react.doctor/docs
|
||||
Learn more about fixing issues, setting up CI/CD, and
|
||||
configuring rules with a config file
|
||||
|
||||
GitHub: https://github.com/millionco/react-doctor
|
||||
Report issues and star the repository!
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
src/actions/billing/retail-checkout.ts(26,42): error TS2322: Type '"2026-05-27.dahlia"' is not assignable to type '"2026-06-24.dahlia"'.
|
||||
src/actions/billing/retail-payment-intent.ts(52,42): error TS2322: Type '"2026-05-27.dahlia"' is not assignable to type '"2026-06-24.dahlia"'.
|
||||
src/actions/billing/stripe-checkout.ts(60,42): error TS2322: Type '"2026-05-27.dahlia"' is not assignable to type '"2026-06-24.dahlia"'.
|
||||
src/actions/billing/stripe-checkout.ts(139,42): error TS2322: Type '"2026-05-27.dahlia"' is not assignable to type '"2026-06-24.dahlia"'.
|
||||
src/actions/billing/stripe-portal.ts(51,42): error TS2322: Type '"2026-05-27.dahlia"' is not assignable to type '"2026-06-24.dahlia"'.
|
||||
src/actions/stripe-connect.ts(44,44): error TS2322: Type '"2026-05-27.dahlia"' is not assignable to type '"2026-06-24.dahlia"'.
|
||||
src/actions/stripe-connect.ts(88,44): error TS2322: Type '"2026-05-27.dahlia"' is not assignable to type '"2026-06-24.dahlia"'.
|
||||
src/actions/stripe-connect.ts(150,44): error TS2322: Type '"2026-05-27.dahlia"' is not assignable to type '"2026-06-24.dahlia"'.
|
||||
src/actions/stripe-connect.ts(240,44): error TS2322: Type '"2026-05-27.dahlia"' is not assignable to type '"2026-06-24.dahlia"'.
|
||||
src/lib/billing.ts(97,42): error TS2322: Type '"2026-05-27.dahlia"' is not assignable to type '"2026-06-24.dahlia"'.
|
||||
src/lib/billing.ts(181,42): error TS2322: Type '"2026-05-27.dahlia"' is not assignable to type '"2026-06-24.dahlia"'.
|
||||
src/lib/stripe-billing.ts(16,7): error TS2322: Type '"2026-05-27.dahlia"' is not assignable to type '"2026-06-24.dahlia"'.
|
||||
tests/unit/create-admin-user.test.ts(121,3): error TS2741: Property 'preconnect' is missing in type 'Mock<Procedure>' but required in type 'typeof fetch'.
|
||||
tests/unit/create-admin-user.test.ts(289,5): error TS2741: Property 'preconnect' is missing in type 'Mock<Procedure>' but required in type 'typeof fetch'.
|
||||
tests/unit/email-service.test.ts(37,5): error TS2741: Property 'preconnect' is missing in type 'Mock<Procedure>' but required in type 'typeof fetch'.
|
||||
tests/unit/email-service.test.ts(71,5): error TS2741: Property 'preconnect' is missing in type 'Mock<Procedure>' but required in type 'typeof fetch'.
|
||||
tests/unit/email-service.test.ts(97,5): error TS2741: Property 'preconnect' is missing in type 'Mock<Procedure>' but required in type 'typeof fetch'.
|
||||
tests/unit/email-service.test.ts(120,5): error TS2741: Property 'preconnect' is missing in type 'Mock<Procedure>' but required in type 'typeof fetch'.
|
||||
@@ -0,0 +1,749 @@
|
||||
|
||||
RUN v4.1.9 /home/tyler/dev/routecomm
|
||||
|
||||
✓ src/lib/__tests__/design-tokens.test.ts (35 tests) 9ms
|
||||
❯ tests/unit/reset-admin-password.test.ts (11 tests | 11 failed) 11ms
|
||||
× rejects unauthenticated callers 5ms
|
||||
× rejects non-platform-admin callers 1ms
|
||||
× rejects an empty email 0ms
|
||||
× looks up the user in neon_auth.user (not the legacy `users` table) 0ms
|
||||
× returns a clear error when no Neon Auth user matches the email 0ms
|
||||
× returns a server-generated temp password and flips must_change_password 0ms
|
||||
× falls back to the public reset-email endpoint when setUserPassword returns FORBIDDEN 0ms
|
||||
× falls back when setUserPassword returns UNAUTHORIZED 0ms
|
||||
× falls back when setUserPassword throws 0ms
|
||||
× does NOT fall back for USER_NOT_FOUND — it surfaces the error 1ms
|
||||
× propagates the public-endpoint error if BOTH paths fail 0ms
|
||||
❯ tests/unit/create-admin-user.test.ts (10 tests | 10 failed) 12ms
|
||||
× rejects unauthenticated callers 5ms
|
||||
× rejects non-platform-admin callers 1ms
|
||||
× uses the privileged admin endpoint when it succeeds 1ms
|
||||
× falls back to /sign-up/email when the admin endpoint fails with FORBIDDEN 1ms
|
||||
× rejects with a clear error when the email is already in use 0ms
|
||||
× surfaces the fallback error if the public sign-up fails 0ms
|
||||
× returns an error explaining the orphaned Neon Auth user 0ms
|
||||
× still returns success when the welcome email fails 0ms
|
||||
× records the email error message when sendWelcomeEmail throws 0ms
|
||||
× creates a platform admin without inserting an admin_user_brands link 1ms
|
||||
✓ tests/unit/water-log-reporting.test.ts (31 tests) 21ms
|
||||
❯ tests/unit/send-password-reset-email.test.ts (8 tests | 8 failed) 11ms
|
||||
× rejects unauthenticated callers 5ms
|
||||
× rejects non-platform-admin callers 1ms
|
||||
× rejects an empty email without calling Neon Auth 1ms
|
||||
× trims and lowercases the email before calling Neon Auth 1ms
|
||||
× returns success when Neon Auth accepts the request 1ms
|
||||
× returns a clear error when Neon Auth returns a structured error 0ms
|
||||
× falls back to the error code if the message is missing 0ms
|
||||
× catches thrown exceptions and surfaces them as a string 0ms
|
||||
✓ tests/unit/email-service.test.ts (5 tests) 23ms
|
||||
✓ src/lib/__tests__/format-date.test.ts (6 tests) 26ms
|
||||
stderr | tests/unit/sign-in-with-google.test.ts > signInWithGoogleAction > returns a structured error when Neon Auth rejects the request
|
||||
[auth/google] signIn.social error: {
|
||||
code: 'PROVIDER_NOT_CONFIGURED',
|
||||
message: 'Google OAuth is not enabled'
|
||||
}
|
||||
|
||||
stderr | tests/unit/sign-in-with-google.test.ts > signInWithGoogleAction > returns a structured error on unexpected exceptions
|
||||
[auth/google] Unexpected error: Error: network down
|
||||
at /home/tyler/dev/routecomm/tests/unit/sign-in-with-google.test.ts:95:34
|
||||
at file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11
|
||||
at file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26
|
||||
at file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20
|
||||
at new Promise (<anonymous>)
|
||||
at runWithCancel (file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:2323:10)
|
||||
at file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:2305:20
|
||||
at new Promise (<anonymous>)
|
||||
at runWithTimeout (file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:2272:10)
|
||||
at file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:2955:64
|
||||
|
||||
stdout | tests/unit/auth-actions.test.ts > signOutAction > calls signOut and redirects to login
|
||||
[auth/sign-out] Signing out
|
||||
|
||||
✓ tests/unit/sign-in-with-google.test.ts (6 tests) 8ms
|
||||
✓ tests/unit/auth-actions.test.ts (1 test) 3ms
|
||||
✓ src/lib/offline/queue.test.ts (8 tests) 18ms
|
||||
✓ src/lib/offline/sync.test.ts (13 tests) 15ms
|
||||
stderr | tests/unit/getAdminUser.test.ts > getAdminUser() > returns null when the user exists but has no admin_user_brands row
|
||||
[admin-permissions] Database query failed: TypeError: membershipRows.map is not a function
|
||||
at /home/tyler/dev/routecomm/src/lib/admin-permissions.ts:99:34
|
||||
at processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
|
||||
✓ tests/unit/getAdminUser.test.ts (11 tests) 8ms
|
||||
✓ tests/unit/water-log-pin.test.ts (21 tests) 365ms
|
||||
✓ tests/unit/passwords.test.ts (9 tests) 485ms
|
||||
|
||||
⎯⎯⎯⎯⎯⎯ Failed Tests 29 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — authorization > rejects unauthenticated callers
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:135:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — authorization > rejects non-platform-admin callers
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:144:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — happy path via auth.admin.createUser > uses the privileged admin endpoint when it succeeds
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:194:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — fallback to public sign-up > falls back to /sign-up/email when the admin endpoint fails with FORBIDDEN
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:253:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — fallback to public sign-up > rejects with a clear error when the email is already in use
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:276:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — fallback to public sign-up > surfaces the fallback error if the public sign-up fails
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:295:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — DB failure after auth success > returns an error explaining the orphaned Neon Auth user
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:310:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — email is best-effort > still returns success when the welcome email fails
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:355:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — email is best-effort > records the email error message when sendWelcomeEmail throws
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:400:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — no brand > creates a platform admin without inserting an admin_user_brands link
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:443:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — authorization > rejects unauthenticated callers
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:106:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[11/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — authorization > rejects non-platform-admin callers
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:116:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[12/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — input handling > rejects an empty email
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:127:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[13/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — input handling > looks up the user in neon_auth.user (not the legacy `users` table)
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:134:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[14/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — input handling > returns a clear error when no Neon Auth user matches the email
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:149:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[15/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — happy path via setUserPassword > returns a server-generated temp password and flips must_change_password
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:159:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[16/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — FORBIDDEN fallback to requestPasswordReset > falls back to the public reset-email endpoint when setUserPassword returns FORBIDDEN
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:188:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[17/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — FORBIDDEN fallback to requestPasswordReset > falls back when setUserPassword returns UNAUTHORIZED
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:207:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[18/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — FORBIDDEN fallback to requestPasswordReset > falls back when setUserPassword throws
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:215:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[19/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — FORBIDDEN fallback to requestPasswordReset > does NOT fall back for USER_NOT_FOUND — it surfaces the error
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:226:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[20/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — FORBIDDEN fallback to requestPasswordReset > propagates the public-endpoint error if BOTH paths fail
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:242:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[21/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — authorization > rejects unauthenticated callers
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:83:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[22/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — authorization > rejects non-platform-admin callers
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:91:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[23/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — input handling > rejects an empty email without calling Neon Auth
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:100:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[24/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — input handling > trims and lowercases the email before calling Neon Auth
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:107:11
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[25/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — happy path > returns success when Neon Auth accepts the request
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:118:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[26/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — error propagation > returns a clear error when Neon Auth returns a structured error
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:130:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[27/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — error propagation > falls back to the error code if the message is missing
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:140:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[28/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — error propagation > catches thrown exceptions and surfaces them as a string
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:147:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[29/29]⎯
|
||||
|
||||
|
||||
Test Files 3 failed | 11 passed (14)
|
||||
Tests 29 failed | 146 passed (175)
|
||||
Start at 17:16:50
|
||||
Duration 755ms (transform 659ms, setup 0ms, import 1.45s, tests 1.01s, environment 495ms)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
|
||||
> route-commerce-platform@2.0.0 test
|
||||
> vitest run
|
||||
|
||||
|
||||
RUN v2.1.9 /home/tyler/dev/routecomm
|
||||
|
||||
✓ src/lib/__tests__/design-tokens.test.ts (35 tests) 6ms
|
||||
✓ tests/unit/send-password-reset-email.test.ts (8 tests) 16ms
|
||||
✓ tests/unit/create-admin-user.test.ts (10 tests) 17ms
|
||||
✓ tests/unit/water-log-reporting.test.ts (31 tests) 27ms
|
||||
✓ tests/unit/email-service.test.ts (5 tests) 60ms
|
||||
✓ src/lib/__tests__/format-date.test.ts (6 tests) 16ms
|
||||
✓ tests/unit/sign-in-with-google.test.ts (6 tests) 13ms
|
||||
✓ tests/unit/reset-admin-password.test.ts (11 tests) 16ms
|
||||
stdout | tests/unit/auth-actions.test.ts > signOutAction > calls signOut and redirects to login
|
||||
[auth/sign-out] Signing out
|
||||
|
||||
✓ tests/unit/auth-actions.test.ts (1 test) 3ms
|
||||
✓ src/lib/offline/queue.test.ts (8 tests) 17ms
|
||||
✓ src/lib/offline/sync.test.ts (13 tests) 18ms
|
||||
❯ tests/unit/getAdminUser.test.ts (11 tests | 3 failed) 8ms
|
||||
× getAdminUser() > returns null when the email is not in the users table 3ms
|
||||
→ expected undefined to be null
|
||||
× getAdminUser() > returns null when the user exists but has no admin_user_brands row 0ms
|
||||
→ expected undefined to be null
|
||||
× getAdminUser() > returns a fully-populated AdminUser for a provisioned brand_admin 1ms
|
||||
→ expected undefined to be 'admin@tuxedo.example' // Object.is equality
|
||||
✓ tests/unit/water-log-pin.test.ts (21 tests) 429ms
|
||||
✓ tests/unit/passwords.test.ts (9 tests) 572ms
|
||||
|
||||
Test Files 1 failed | 13 passed (14)
|
||||
Tests 3 failed | 172 passed (175)
|
||||
Start at 21:56:10
|
||||
Duration 1.09s (transform 671ms, setup 0ms, collect 1.54s, tests 1.22s, environment 615ms, prepare 1.03s)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,393 @@
|
||||
# Water Log — remove platform-login dependency — 2026-07-01
|
||||
|
||||
> Type: Bug fix + refactor (auth layer cleanup)
|
||||
> Branch: `fix/water-log-no-platform-login`
|
||||
> Status: Draft — awaiting user review
|
||||
|
||||
## 1. Context
|
||||
|
||||
The Water Log module is **PIN-based and self-contained**: `/water` (irrigator)
|
||||
and `/water/admin/login` (brand water admin) both authenticate via a
|
||||
4-digit PIN that mints a `wl_session` or `wl_admin_session` cookie. Neither
|
||||
surface is meant to require a platform admin (Neon Auth) login.
|
||||
|
||||
The middleware already agrees — `src/proxy.ts` lists `/water` and
|
||||
`/api/water-photo-upload` among the public routes. The `/water` page
|
||||
itself loads with no cookie and shows the language → role → PIN flow.
|
||||
|
||||
**But every server action in `src/actions/water-log/{field,admin,settings}.ts`
|
||||
opens with `await getSession()` from `@/lib/auth`.** That call hits Neon
|
||||
Auth on every PIN-screen interaction. In production:
|
||||
|
||||
- When `NEON_AUTH_BASE_URL` is misconfigured, `getSession()` short-circuits
|
||||
to `{ data: null, error: null }` and the rest of the action runs.
|
||||
- When it is configured, the wrapper falls through to the real Neon Auth
|
||||
`getSession()`, which (a) checks the request for a session cookie (none
|
||||
is present for `/water` users), (b) makes a network round-trip to
|
||||
`NEON_AUTH_BASE_URL` anyway, and (c) may hang or return an error if the
|
||||
Neon Auth service is slow / unhealthy / cross-region.
|
||||
|
||||
User-visible result: **PIN submission hangs or fails on `/water` even
|
||||
though the user is doing exactly what the design asks them to do — enter
|
||||
a 4-digit PIN — and is correctly authenticated by the cookie they
|
||||
receive in response.**
|
||||
|
||||
The root cause is leftover copy-paste from the site-admin actions, which
|
||||
*do* legitimately call `getSession()` because they sit behind
|
||||
`/admin/water-log/*` and are gated by `admin_users`. The field and
|
||||
admin-PIN actions have no such dependency; the `getSession()` lines are
|
||||
spurious and should be removed.
|
||||
|
||||
## 2. Goals
|
||||
|
||||
- **`/water` PIN entry works with no platform login, no Neon Auth call,
|
||||
no `dev_session` cookie.** A ditch rider in the field can complete the
|
||||
full language → role → PIN → entry flow with no cookies set on the
|
||||
request other than the `wl_session` minted on success.
|
||||
- **`/water/admin/login` works the same way** for the brand water admin
|
||||
portal.
|
||||
- **The site-admin `/admin/water-log/*` surface is unchanged** —
|
||||
it continues to require a platform admin (Neon Auth + `admin_users`).
|
||||
- **No DB migration, no schema change, no env-var change.** Deploy is a
|
||||
pure code change.
|
||||
- **Regression guard:** an E2E test runs a fresh, cookie-free Playwright
|
||||
context against `/water` and proves the PIN flow works.
|
||||
|
||||
## 3. Non-goals
|
||||
|
||||
- No change to admin-permissions, Neon Auth, or the PIN hashing library.
|
||||
- No change to middleware, page components, or the existing API routes
|
||||
(all of which are already clean — see §6).
|
||||
- No refactor of the irrigation-season / reporting / CSV layers.
|
||||
- No change to the photo-upload or QR-code APIs.
|
||||
- No attempt to enforce brand_id on the field PIN flow beyond the existing
|
||||
data model (irrigators carry a `brand_id`, and `wl_session` is keyed by
|
||||
`irrigator_id`, so brand isolation is preserved by the join).
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
### 4.1 Three auth gates, three helpers
|
||||
|
||||
Every water-log server action is gated by exactly one helper, matched
|
||||
to its consumer:
|
||||
|
||||
| Surface | Cookie | Helper | Backed by |
|
||||
|---|---|---|---|
|
||||
| `/water` (irrigator) | `wl_session` | `requireFieldSession()` | `cookies()` + `water_sessions` row |
|
||||
| `/water/admin/*` (brand water admin) | `wl_admin_session` | `requireWaterAdminSession()` | `cookies()` + `water_admin_sessions` row |
|
||||
| `/admin/water-log/*` (site admin) | Neon Auth | `requireWaterAdminPermission()` | `getAdminUser()` + `admin_users.can_manage_water_log` |
|
||||
|
||||
The three `require*` helpers in `auth.ts` **never call `getSession()`**.
|
||||
The two PIN-only helpers (`requireFieldSession`, `requireWaterAdminSession`)
|
||||
read the cookie directly, join the cookie value to the appropriate
|
||||
session row in Postgres, and return. No network round-trip, no Neon
|
||||
Auth dependency, no platform-admin requirement.
|
||||
|
||||
The site-admin helper (`requireWaterAdminPermission`) keeps using
|
||||
`getAdminUser()` because that is the correct auth check for
|
||||
`/admin/water-log/*` (which sits behind Neon Auth in the middleware
|
||||
and on the server).
|
||||
|
||||
> Note: the action `verifyWaterAdminPin` (in `settings.ts`) is now a
|
||||
> fully PIN-only flow — it does NOT call `getAdminUser()` or
|
||||
> `getSession()` at all. The `adminUserId` on the new
|
||||
> `water_admin_sessions` row is always the zero-UUID placeholder. This
|
||||
> is because `/water/admin/login` is the Tuxedo-brand-specific entry
|
||||
> point and must work for users who are not signed into the platform.
|
||||
> Audit attribution is intentionally deferred to a future ticket.
|
||||
|
||||
### 4.2 New file: `src/actions/water-log/auth.ts`
|
||||
|
||||
Centralized auth layer for the water-log module. Holds the three helpers
|
||||
plus small typed return shapes.
|
||||
|
||||
```ts
|
||||
// src/actions/water-log/auth.ts (sketch)
|
||||
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { withPlatformAdmin } from "@/db/client";
|
||||
import {
|
||||
waterSessions, waterIrrigators,
|
||||
waterAdminSessions, waterAdminSettings,
|
||||
} from "@/db/schema/water-log";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
type FieldSession =
|
||||
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type AdminPinSession =
|
||||
| { ok: true; sessionId: string; brandId: string; adminUserId: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type SiteAdminPermission =
|
||||
| { ok: true; adminUser: AdminUser }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Read wl_session, look up the row + irrigator. No Neon Auth. */
|
||||
export async function requireFieldSession(): Promise<FieldSession> { ... }
|
||||
|
||||
/** Read wl_admin_session, look up the row. No Neon Auth. */
|
||||
export async function requireWaterAdminSession(): Promise<AdminPinSession> { ... }
|
||||
|
||||
/** Site-admin gate: getAdminUser() + can_manage_water_log. */
|
||||
export async function requireWaterAdminPermission(): Promise<SiteAdminPermission> { ... }
|
||||
|
||||
/** Read the active water-admin session row (or null). Cheap, idempotent. */
|
||||
export async function getWaterAdminSession(): Promise<AdminPinSession | null> { ... }
|
||||
|
||||
/** Read the active field session row (or null). Cheap, idempotent. */
|
||||
export async function getFieldSessionUser(): Promise<{ userId; name; brandId; role } | null> { ... }
|
||||
```
|
||||
|
||||
The helpers are exact ports of the existing local helpers in
|
||||
`field.ts` and `admin.ts` — behavior is unchanged, only the import path
|
||||
moves.
|
||||
|
||||
### 4.3 File-by-file changes
|
||||
|
||||
**`src/actions/water-log/field.ts`**
|
||||
- Drop `import { getSession } from "@/lib/auth"`.
|
||||
- Drop every `await getSession();` line (10 occurrences).
|
||||
- Move `requireFieldSession`, `getFieldSessionUser`, `getWaterAdminSession`,
|
||||
`getWaterSession` into `auth.ts`. Import from there.
|
||||
- `setWaterLang()` keeps `cookies()` only.
|
||||
- `verifyWaterPin`, `getWaterHeadgates`, `submitWaterEntry`, `logoutWater`,
|
||||
`logoutWaterAdmin` all remain — no behavior change other than
|
||||
dropping the spurious `getSession()` calls.
|
||||
|
||||
**`src/actions/water-log/admin.ts`**
|
||||
- Drop `import { getSession } from "@/lib/auth"`.
|
||||
- Drop every `await getSession();` line (18 occurrences).
|
||||
- Move `requireWaterAdminSession` and `requireWaterAdminPermission` into
|
||||
`auth.ts`. Import from there.
|
||||
- Action-by-action gate stays as today: mutations/reads in `admin.ts`
|
||||
use `requireWaterAdminPermission()` because every action that lives
|
||||
in this file is invoked from `/admin/water-log/*` (site-admin path).
|
||||
|
||||
**`src/actions/water-log/settings.ts`**
|
||||
- Drop `import { getSession } from "@/lib/auth"`.
|
||||
- Drop every `await getSession();` line (4 occurrences, including the
|
||||
one in `verifyWaterAdminPin`).
|
||||
- `getWaterAdminSettings`, `updateWaterAdminSettings`, `regenerateAdminPin`
|
||||
keep their existing `getAdminUser()` gate (site-admin path).
|
||||
- `verifyWaterAdminPin` becomes a pure PIN-only flow: validate format,
|
||||
look up `water_admin_settings` for the brand, verify PIN, mint
|
||||
`wl_admin_session` cookie. **No `getSession()` call. No
|
||||
`getAdminUser()` call.** `adminUserId` is always the zero-UUID
|
||||
placeholder. The page is the Tuxedo-brand-specific entry point and
|
||||
must work without a platform login.
|
||||
|
||||
### 4.4 Why `verifyWaterAdminPin` is a special case
|
||||
|
||||
`verifyWaterAdminPin` is the most-affected function in this PR. Today:
|
||||
|
||||
1. It called `await getSession()` (Neon Auth) — dropped.
|
||||
2. It called `getAdminUser()` inside the success path to attach the
|
||||
caller's platform-admin user to the new `wl_admin_session` row for
|
||||
audit. That call was wrapped in a try/catch that fell back to a
|
||||
zero-UUID `adminUserId`, but the underlying `getSession()` call
|
||||
inside `getAdminUser()` could still corrupt the Next.js cookie
|
||||
store and cause the subsequent `cookies().set("wl_admin_session",
|
||||
...)` to throw — surfacing as a 500 to users without a platform
|
||||
login. The call is now **fully removed**; `adminUserId` is always
|
||||
the zero-UUID placeholder. Audit attribution is a follow-up.
|
||||
|
||||
This is the right shape because the water-admin PIN flow is for ditch
|
||||
riders / brand water admins, who often aren't platform admins. Tying
|
||||
the brand-admin session to a platform-admin user is convenient for
|
||||
audit, not required for the flow to work.
|
||||
|
||||
## 5. Data flow
|
||||
|
||||
### 5.1 `verifyWaterPin` (irrigator)
|
||||
|
||||
```
|
||||
client POST via server action: verifyWaterPin(brandId, pin)
|
||||
└─ validatePin(pin) ── format check
|
||||
└─ withPlatformAdmin(db => ...) ── look up irrigator
|
||||
WHERE active = true ── across all brands
|
||||
└─ match = irrigators.find(i => verifyPin(...)) ── scrypt compare
|
||||
└─ if no match: 200ms delay, return error
|
||||
└─ withBrand(brandId, db => ...)
|
||||
INSERT water_sessions (irrigator_id, expires_at = now+8h)
|
||||
UPDATE water_irrigators SET last_used_at
|
||||
└─ cookies().set("wl_session", sessionId, ...)
|
||||
└─ return { success: true, role, name, ... }
|
||||
```
|
||||
|
||||
No `getSession()`. No `getAdminUser()`. No network call beyond the local
|
||||
DB round-trips.
|
||||
|
||||
### 5.2 `verifyWaterAdminPin` (brand water admin)
|
||||
|
||||
```
|
||||
client POST via server action: verifyWaterAdminPin(brandId, pin)
|
||||
└─ validatePin(pin)
|
||||
└─ withBrand(brandId, db => ...)
|
||||
SELECT water_admin_settings WHERE brand_id
|
||||
if !settings or !settings.enabled or !settings.pinHash → return error
|
||||
if !verifyPin(pin, settings.pinHash) → 200ms delay, return error
|
||||
try { adminUser = await getAdminUser() } catch { adminUser = null }
|
||||
INSERT water_admin_sessions (brand_id, admin_user_id, expires_at)
|
||||
└─ cookies().set("wl_admin_session", sessionId, ...)
|
||||
└─ return { success: true, session_id }
|
||||
```
|
||||
|
||||
### 5.3 `submitWaterEntry` (irrigator mutation)
|
||||
|
||||
```
|
||||
client POST via server action: submitWaterEntry(...)
|
||||
└─ requireFieldSession() → { ok: false, error } or { ok: true, userId, brandId }
|
||||
└─ validate measurement / notes / lat / lng
|
||||
└─ withBrand(brandId, db => ...)
|
||||
SELECT headgate (verify it belongs to brand)
|
||||
INSERT water_log_entries
|
||||
UPDATE water_headgates SET last_used_at
|
||||
(optional) logAlert() if threshold breach
|
||||
└─ return { success: true, entry_id }
|
||||
```
|
||||
|
||||
If `requireFieldSession()` returns `ok: false`, the action returns
|
||||
`{ success: false, error: "Session expired or invalid" }`. The client
|
||||
(`WaterFieldClient`) already handles this: it resets to the language
|
||||
step and shows the "Session expired" message. **No change to that
|
||||
client behavior is needed.**
|
||||
|
||||
## 6. Error handling
|
||||
|
||||
| Failure | Helper return | Caller return | Client behavior |
|
||||
|---|---|---|---|
|
||||
| No `wl_session` cookie | `{ ok: false, error: "Not logged in" }` | `{ success: false, error: "Session expired or invalid" }` | Reset to language step |
|
||||
| `wl_session` cookie but no matching row | same as above | same | same |
|
||||
| Session row past `expires_at` | same as above (best-effort delete) | same | same |
|
||||
| Irrigator row marked `active = false` | `{ ok: false, error: "User is inactive" }` | `{ success: false, error: "User is inactive" }` | Reset to language step |
|
||||
| `wl_admin_session` missing / expired | `null` (from `getWaterAdminSession`) | `{ success: false, error: "Not signed in" }` | Bounce to `/water/admin/login` |
|
||||
| Site admin not signed in | `{ ok: false, error: "Not signed in" }` | `{ success: false, error: ... }` | Toast + stay |
|
||||
| Site admin signed in but `can_manage_water_log` false | `{ ok: false, error: "Not permitted" }` | `{ success: false, error: "Forbidden" }` | Toast + redirect |
|
||||
|
||||
## 7. Testing
|
||||
|
||||
### 7.1 Unit (`tests/unit/water-log-auth.test.ts`, new)
|
||||
|
||||
Each helper, no network:
|
||||
|
||||
- `requireFieldSession()`:
|
||||
- Returns `{ ok: false, error: "Not logged in" }` when no cookie.
|
||||
- Returns `{ ok: false, error: "Session expired" }` when cookie points
|
||||
at a row with `expires_at` in the past.
|
||||
- Returns `{ ok: false, error: "Session not found" }` when cookie has
|
||||
no matching row.
|
||||
- Returns `{ ok: false, error: "User is inactive" }` when irrigator
|
||||
`active = false`.
|
||||
- Returns `{ ok: true, userId, brandId, role }` on happy path.
|
||||
- **Asserts no `getSession` import is reachable from this module**
|
||||
(literal `import.meta.glob` or a vitest module-mock trick).
|
||||
|
||||
- `requireWaterAdminSession()`:
|
||||
- Returns `{ ok: false, error: "Not signed in" }` when no cookie.
|
||||
- Returns `{ ok: false, error: "Session expired" }` when past expiry.
|
||||
- Returns `{ ok: true, ... }` on happy path.
|
||||
- No `getSession` import reachable.
|
||||
|
||||
- `requireWaterAdminPermission()`:
|
||||
- Mock `getAdminUser` to return `null` → `{ ok: false, error: "Not signed in" }`.
|
||||
- Mock `getAdminUser` to return user without `can_manage_water_log`
|
||||
→ `{ ok: false, error: "Not permitted" }`.
|
||||
- Mock to return platform_admin → `{ ok: true, ... }`.
|
||||
- This helper is allowed to call `getAdminUser()` — that is its
|
||||
defined behavior. No "no getSession" assertion here.
|
||||
|
||||
### 7.2 E2E (`tests/water-log.spec.ts`, new block)
|
||||
|
||||
New `test.describe("Water Log — no platform login required")`:
|
||||
|
||||
```ts
|
||||
test("GET /water renders the PIN flow with no cookies set", async ({ browser }) => {
|
||||
// Fresh context: no storageState, no cookies, no dev_session.
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const res = await page.goto(`${BASE}/water`);
|
||||
expect(res?.status()).toBe(200);
|
||||
// The page goes through language → role → pin before showing the input.
|
||||
// We assert it never 302s to /login.
|
||||
const allResponses: string[] = [];
|
||||
page.on("response", (r) => allResponses.push(r.url()));
|
||||
// Click through the language step
|
||||
await page.getByRole("button", { name: /english/i }).click();
|
||||
// Click the "Irrigator" button (the lighter one)
|
||||
await page.getByRole("button", { name: /irrigator/i }).click();
|
||||
// PIN input is now visible
|
||||
const pin = page.locator('input[type="password"][inputmode="numeric"]').first();
|
||||
await expect(pin).toBeVisible({ timeout: 10_000 });
|
||||
// Confirm we never hit /login
|
||||
expect(allResponses.filter((u) => u.includes("/login"))).toHaveLength(0);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test("setWaterLang server action does not require auth", async ({ request }) => {
|
||||
// Empty storage state, no cookies.
|
||||
const ctx = await request.newContext({ baseURL: BASE, storageState: { cookies: [], origins: [] } });
|
||||
// We don't have a direct POST endpoint for setWaterLang; instead hit
|
||||
// the page and check the language cookie gets set without a redirect.
|
||||
const res = await ctx.get("/water");
|
||||
expect(res.status()).toBe(200);
|
||||
await ctx.dispose();
|
||||
});
|
||||
```
|
||||
|
||||
The existing `tests/water-log.spec.ts` blocks for the `/water/admin/login`
|
||||
and `/admin/water-log` redirects remain — they continue to pass because
|
||||
we only relax the field and admin-PIN paths, not the site-admin path.
|
||||
|
||||
### 7.3 Manual smoke (post-deploy)
|
||||
|
||||
1. Open `/water` in a private window. Confirm language → role → PIN.
|
||||
2. Enter a wrong PIN — confirm error toast, no redirect to `/login`.
|
||||
3. Enter a correct PIN — confirm the entry form loads with headgates.
|
||||
4. Submit an entry — confirm the success banner.
|
||||
5. Open `/water/admin/login` in another private window. Same flow.
|
||||
6. Sign in as a site admin in a third window, navigate to
|
||||
`/admin/water-log` — confirm it still requires the platform login.
|
||||
|
||||
## 8. Out of scope (explicit)
|
||||
|
||||
- No DB migration. (`water_sessions`, `water_admin_sessions`,
|
||||
`water_admin_settings` already have all columns the auth helpers
|
||||
read.)
|
||||
- No new RPC. The auth helpers run direct Drizzle queries against the
|
||||
pool.
|
||||
- No env-var changes.
|
||||
- No middleware change. `/water` and the water API routes are already
|
||||
in `PUBLIC_ROUTES`.
|
||||
- No change to PIN hashing (`src/lib/water-log-pin.ts` is correct).
|
||||
- No change to the photo-upload route — it already uses the cookie.
|
||||
- No change to QR-code routes — they're truly public.
|
||||
- No change to `/api/water-logs/export` or `/api/v1/water-logs` —
|
||||
both are correctly site-admin-gated via `getAdminUser()` and
|
||||
belong on the platform login.
|
||||
|
||||
## 9. Rollout
|
||||
|
||||
- Single branch `fix/water-log-no-platform-login`, single PR.
|
||||
- Merge → push to `origin/main` → `.gitea/workflows/deploy.yml` runs
|
||||
the normal build + migration gate (no migration in this change).
|
||||
- Existing `wl_session` / `wl_admin_session` cookies continue to work.
|
||||
- No data backfill.
|
||||
- Rollback = revert the commit. The `getSession()` calls being removed
|
||||
were inert in practice (just slow / occasionally failing), so any
|
||||
caller that worked before this change will continue to work after.
|
||||
|
||||
## 10. Open questions
|
||||
|
||||
None at design time. Decisions taken:
|
||||
|
||||
- Helpers move to `auth.ts`, not split across files.
|
||||
- `verifyWaterAdminPin` keeps the best-effort `getAdminUser()` call for
|
||||
audit, wrapped in try/catch.
|
||||
- `getWaterAdminSettings` / `updateWaterAdminSettings` / `regenerateAdminPin`
|
||||
stay site-admin-gated (these mutate brand-level settings, which is a
|
||||
site-admin action — the brand water admin does not manage them).
|
||||
- Field session type union is preserved verbatim from current `field.ts`.
|
||||
@@ -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 `pg` Pool.
|
||||
|
||||
---
|
||||
|
||||
## Day-to-day usage
|
||||
|
||||
### Add a headgate
|
||||
1. `/admin/water-log` → scroll to **Headgates** → **+ Add Headgate**.
|
||||
2. Enter a unique name (e.g. "North Field Gate 1"), default unit,
|
||||
optional thresholds.
|
||||
3. The new headgate gets a printable QR code. Print it and stick it
|
||||
on the gate.
|
||||
|
||||
### Add a water user
|
||||
1. **Water Users** → **+ Add User**.
|
||||
2. Enter name, choose role (Admin or Irrigator), pick language.
|
||||
3. A 4-digit PIN is generated. **Write it down now** — it is shown
|
||||
once and never recoverable.
|
||||
4. Hand the PIN to the worker. They sign in at `/water` with just
|
||||
that.
|
||||
|
||||
### Submit a flow entry (irrigator)
|
||||
1. Open `/water` on a phone → enter 4-digit PIN.
|
||||
2. Pick the headgate (or scan the QR code — it deep-links to that
|
||||
gate).
|
||||
3. Enter the measurement, duration, method, optional notes/photo.
|
||||
4. Tap **Submit**. Total gallons is auto-computed when CFS × duration
|
||||
is known.
|
||||
5. If the reading crosses a high/low threshold, an alert row is
|
||||
written to `water_alert_log` and (if enabled) an SMS is dispatched
|
||||
to the configured phone.
|
||||
|
||||
### Edit or delete an entry (admin)
|
||||
- From the **Recent Entries** table, click any row.
|
||||
- Edit form is gated by `canEditEntries` / `canDeleteEntries` in
|
||||
`water_admin_settings`. Defaults: edit ✅, delete ✅.
|
||||
|
||||
### Reset a PIN
|
||||
- **Water Users** row → **Reset PIN** → new PIN is generated and
|
||||
shown once.
|
||||
|
||||
### Export CSV
|
||||
- **Recent Entries** → **Export CSV** button. Or hit
|
||||
`GET /api/water-logs/export?format=csv` (auth-gated, requires
|
||||
`can_manage_water_log`).
|
||||
|
||||
---
|
||||
|
||||
## API surface
|
||||
|
||||
| Route | Method | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/api/water-admin-auth` | POST | none (PIN-protected) | Exchange admin PIN for `wl_admin_session` cookie |
|
||||
| `/api/water-logs/export` | GET | admin | Stream entries as JSON or CSV |
|
||||
|
||||
`POST /api/water-admin-auth` body:
|
||||
```json
|
||||
{ "brandId": "64294306-5f42-463d-a5e8-2ad6c81a96de", "pin": "1234" }
|
||||
```
|
||||
|
||||
On success, sets the `wl_admin_session` cookie. On failure, returns
|
||||
a generic error (we don't leak whether the brand has a PIN configured
|
||||
vs. whether the PIN is wrong).
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit (Vitest)
|
||||
```bash
|
||||
npm test -- tests/unit/water-log
|
||||
```
|
||||
Covers:
|
||||
- PIN format validation + weak-PIN detection
|
||||
- scrypt round-trip + tampering
|
||||
- Reporting utilities (CSV escaping, date filters, season detection)
|
||||
- Display age helpers
|
||||
|
||||
### E2E (Playwright)
|
||||
```bash
|
||||
npx playwright test water-log
|
||||
```
|
||||
Covers:
|
||||
- `/water` and `/water/admin/login` render with PIN form
|
||||
- PIN input strips non-digits and caps at 4 chars
|
||||
- Wrong PIN does not navigate
|
||||
- `/admin/water-log/*` redirects unauthenticated users
|
||||
- `/api/water-logs/export` and `/api/water-admin-auth` are auth-gated
|
||||
|
||||
For the full DB-backed workflow (add headgate → add user → submit →
|
||||
export), set `WATER_LOG_E2E_DB=1` and run the same command against a
|
||||
test database.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
No Water Log–specific env vars are required. The module uses the
|
||||
existing `DATABASE_URL` and (optionally) the MinIO bucket
|
||||
`MINIO_BUCKET_WATER_LOGS` for photo uploads.
|
||||
|
||||
If you want high/low threshold SMS alerts, configure
|
||||
`/admin/water-log/settings` with a phone number — SMS dispatch uses
|
||||
the brand's existing Twilio config (same as Harvest Reach).
|
||||
|
||||
---
|
||||
|
||||
## Migration history
|
||||
|
||||
- `0001_init.sql` — initial `water_*` tables + RLS policies.
|
||||
- `0090_water_log_completion.sql` — adds `headgate_token`, threshold
|
||||
fields, `role` on irrigators, `method` + `logged_date` on entries,
|
||||
plus the `water_admin_*`, `water_audit_log`, and `water_alert_log`
|
||||
tables.
|
||||
|
||||
---
|
||||
|
||||
## Admin function checklist
|
||||
|
||||
Manual regression pass after changes:
|
||||
|
||||
### Field side
|
||||
- [ ] `/water` PIN screen loads, accepts 4 digits, rejects letters
|
||||
- [ ] Wrong PIN shows an error, does not redirect
|
||||
- [ ] Correct PIN shows the entry form with the user's headgates
|
||||
- [ ] Submitting an entry creates a row, shows confirmation, returns
|
||||
to the form
|
||||
- [ ] QR-code link (`/water?h=TOKEN`) pre-selects the headgate
|
||||
- [ ] High/low threshold entries write a `water_alert_log` row
|
||||
|
||||
### Admin side
|
||||
- [ ] `/admin/water-log` shows headgates, users, recent entries
|
||||
- [ ] Add headgate → appears in list + QR can be generated
|
||||
- [ ] Edit headgate thresholds → reflected in the field form
|
||||
- [ ] Add user → PIN shown once, user appears in list
|
||||
- [ ] Reset PIN → new PIN shown, old sessions invalidated
|
||||
- [ ] Edit entry → measurement, notes, method all updatable
|
||||
- [ ] Delete entry → row removed (or soft-flagged per audit policy)
|
||||
- [ ] Filter by date range / headgate / user / method works
|
||||
- [ ] CSV export downloads valid CSV
|
||||
- [ ] Audit log shows the actor for every change
|
||||
|
||||
### Settings + portal
|
||||
- [ ] `/admin/water-log/settings` shows current config
|
||||
- [ ] Toggling "Enable Admin Portal" blocks `/water/admin/login`
|
||||
- [ ] Regenerating admin PIN signs out all current admin sessions
|
||||
- [ ] Session duration slider clamps to 1–168 hours
|
||||
|
||||
### Edge cases
|
||||
- [ ] Zero data: empty states render, no crashes
|
||||
- [ ] Invalid PIN: form rejects, error is friendly
|
||||
- [ ] Duplicate headgate name: server rejects, UI shows error
|
||||
- [ ] Very large measurement (1e9): renders, doesn't blow up float
|
||||
- [ ] 1,000+ entries: list paginates, filters stay responsive
|
||||
- [ ] Concurrent submissions: both rows present, no lost writes
|
||||
@@ -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,10 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Ignore legacy .js scripts that use CommonJS
|
||||
"scripts/**",
|
||||
"supabase/**",
|
||||
"fix-agents.js",
|
||||
]),
|
||||
// Allow setState in useEffect for PWA prompts and client-side state initialization
|
||||
{
|
||||
@@ -20,6 +24,14 @@ const eslintConfig = defineConfig([
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
},
|
||||
},
|
||||
// Relax some rules for legacy code
|
||||
{
|
||||
files: ["db/**", "scripts/**", "supabase/**"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
+61
-6
@@ -1,6 +1,21 @@
|
||||
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,
|
||||
|
||||
@@ -71,18 +86,44 @@ 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 },
|
||||
];
|
||||
},
|
||||
|
||||
@@ -97,6 +138,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
|
||||
@@ -113,6 +159,15 @@ const nextConfig: NextConfig = {
|
||||
fullUrl: process.env.NODE_ENV === "development",
|
||||
},
|
||||
},
|
||||
|
||||
// Allow cross-origin requests to dev resources (HMR, dev manifest, etc.)
|
||||
// from non-localhost hosts (e.g. LAN IPs, dev tunnels). Read from
|
||||
// `NEXT_ALLOWED_DEV_ORIGINS` as a comma-separated list. Empty in production.
|
||||
allowedDevOrigins: process.env.NEXT_ALLOWED_DEV_ORIGINS
|
||||
? process.env.NEXT_ALLOWED_DEV_ORIGINS.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
+31
-13
@@ -1,44 +1,54 @@
|
||||
{
|
||||
"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",
|
||||
"@stripe/react-stripe-js": "^6.6.0",
|
||||
"@stripe/stripe-js": "^9.7.0",
|
||||
"@supabase/supabase-js": "^2.105.3",
|
||||
"@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-themes": "^0.4.6",
|
||||
"openai": "^6.37.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"posthog-js": "^1.378.1",
|
||||
"posthog-node": "^5.35.11",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"server-only": "^0.0.1",
|
||||
"square": "^44.0.1",
|
||||
"stripe": "^22.1.1",
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^4.4.3"
|
||||
@@ -48,17 +58,25 @@
|
||||
"@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",
|
||||
"react-doctor": "^0.5.8",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"overrides": "{}"
|
||||
}
|
||||
|
||||
+15
-5
@@ -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 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# pnpm workspace configuration.
|
||||
#
|
||||
# Hardening flags recommended by react-doctor's `require-pnpm-hardening`
|
||||
# rule. `minimumReleaseAge` delays installs until new releases have had
|
||||
# time to be vetted (mitigates "catch-and-unpublish" supply-chain
|
||||
# attacks). `trustPolicy: no-downgrade` prevents pnpm from silently
|
||||
# accepting packages whose trust signals (provenance / signatures)
|
||||
# weaken between updates.
|
||||
#
|
||||
# See: https://react.doctor/docs/rules/react-doctor/require-pnpm-hardening
|
||||
|
||||
minimumReleaseAge: 10080 # 7 days, in minutes
|
||||
|
||||
trustPolicy: no-downgrade
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user