Compare commits
125 Commits
2daa8fd4b6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 19377ea6cf | |||
| c71354d2ae | |||
| da09cfbd1a | |||
| 68851cfede | |||
| b90f0fae52 | |||
| 5a72705e71 | |||
| 129c9d253c | |||
| f8434685a8 | |||
| 60783131ae | |||
| 772e23ded8 | |||
| 5dc9a4604f | |||
| 5188960bd2 | |||
| 6f1aa37a26 | |||
| c7c83890e4 | |||
| e98bbc220f | |||
| 3db642e76b | |||
| 9d0e325c26 | |||
| 34d88d594f | |||
| 06aac296f1 | |||
| 9e6accb3df | |||
| 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 |
@@ -79,3 +79,18 @@ CRON_SECRET=replace-me-with-a-random-string
|
|||||||
# the server actions — but you can override it with:
|
# the server actions — but you can override it with:
|
||||||
# TUXEDO_BRAND_ID=64294306-5f42-463d-a5e8-2ad6c81a96de
|
# TUXEDO_BRAND_ID=64294306-5f42-463d-a5e8-2ad6c81a96de
|
||||||
# See docs/water-log.md for the full module guide.
|
# 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
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ jobs:
|
|||||||
MINIO_BUCKET_WATER_LOGS: ${{ secrets.MINIO_BUCKET_WATER_LOGS }}
|
MINIO_BUCKET_WATER_LOGS: ${{ secrets.MINIO_BUCKET_WATER_LOGS }}
|
||||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||||
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||||
|
SMARTSHEET_TOKEN_ENC_KEY: ${{ secrets.SMARTSHEET_TOKEN_ENC_KEY }}
|
||||||
|
SMARTSHEET_CRON_SECRET: ${{ secrets.SMARTSHEET_CRON_SECRET }}
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
@@ -108,6 +110,8 @@ jobs:
|
|||||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||||
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
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 }}
|
SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
@@ -173,6 +177,8 @@ jobs:
|
|||||||
printf 'MINIMAX_API_KEY=%s\n' "$MINIMAX_API_KEY"
|
printf 'MINIMAX_API_KEY=%s\n' "$MINIMAX_API_KEY"
|
||||||
printf 'MINIMAX_BASE_URL=%s\n' "$MINIMAX_BASE_URL"
|
printf 'MINIMAX_BASE_URL=%s\n' "$MINIMAX_BASE_URL"
|
||||||
printf 'CRON_SECRET=%s\n' "$CRON_SECRET"
|
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"
|
} > "$ENV_FILE"
|
||||||
|
|
||||||
# Upload env file and sync build output
|
# Upload env file and sync build output
|
||||||
@@ -193,10 +199,45 @@ jobs:
|
|||||||
echo "Ensuring migration directories on server and copying runner + SQL..."
|
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"
|
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/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
|
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
|
# Install deps and restart on server
|
||||||
echo "Installing deps and restarting PM2..."
|
echo "Installing deps and restarting PM2..."
|
||||||
ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && pm2 restart route-commerce || pm2 start npm --name route-commerce -- start -- -p 3100 && pm2 save && sleep 4 && curl -f -s http://localhost:3100/api/health/db-schema || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }"
|
# IMPORTANT: With `output: "standalone"` in next.config.ts we MUST
|
||||||
|
# run `node .next/standalone/server.js` (not `next start`) — `next start`
|
||||||
|
# against a standalone build is unsupported and silently disables the
|
||||||
|
# image optimizer (every `/_next/image?url=...` returns
|
||||||
|
# `"url" parameter is not allowed`). See commit 129c9d2.
|
||||||
|
#
|
||||||
|
# We also need to (1) ship the standalone loader script
|
||||||
|
# `scripts/start-standalone.cjs`, (2) copy `.next/static/` into
|
||||||
|
# `.next/standalone/.next/static/` so the standalone server can
|
||||||
|
# serve client-side JS/CSS chunks, (3) copy `public/` into
|
||||||
|
# `.next/standalone/public/` (REQUIRED for Next.js standalone —
|
||||||
|
# without it, `/_next/image?url=/brand-logos/...` returns 400
|
||||||
|
# because the optimizer can't resolve local relative paths),
|
||||||
|
# and (4) load `.env` via a Node loader — bash's
|
||||||
|
# `set -a; . ./.env` truncates DATABASE_URL at the `&` in
|
||||||
|
# `&channel_binding=require`.
|
||||||
|
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/start-standalone.cjs tyler@route.crispygoat.com:$APP_DIR/scripts/start-standalone.cjs 2>/dev/null || true
|
||||||
|
ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "set -e; cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && \
|
||||||
|
# Standalone server reads .next/static/ relative to itself.
|
||||||
|
mkdir -p .next/standalone/.next && \
|
||||||
|
rm -rf .next/standalone/.next/static && \
|
||||||
|
cp -r .next/static .next/standalone/.next/static && \
|
||||||
|
# Next.js standalone REQUIRES public/ in the standalone tree;
|
||||||
|
# without it, the image optimizer returns 400 on local-relative
|
||||||
|
# image URLs (e.g. /brand-logos/.../logo.png) and storefront
|
||||||
|
# logos / favicons go blank. Mirror public/ → .next/standalone/public/.
|
||||||
|
rm -rf .next/standalone/public && \
|
||||||
|
mkdir -p .next/standalone/public && \
|
||||||
|
cp -r public/. .next/standalone/public/ && \
|
||||||
|
# Start with the wrapper that loads .env and exec's the server.
|
||||||
|
# `pm2 start` is idempotent via `pm2 restart` after the first run.
|
||||||
|
pm2 delete route-commerce 2>/dev/null || true; \
|
||||||
|
PORT=3100 HOSTNAME=0.0.0.0 pm2 start scripts/start-standalone.cjs --name route-commerce --interpreter /home/tyler/.cache/act/tool_cache/node/22.22.3/x64/bin/node 2>&1 | tail -3 && \
|
||||||
|
pm2 save && sleep 4 && \
|
||||||
|
curl -fsS http://localhost:3100/api/health/db-schema >/dev/null || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }"
|
||||||
|
|
||||||
echo "Deployed successfully"
|
echo "Deployed successfully"
|
||||||
@@ -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"
|
||||||
@@ -42,6 +42,12 @@ supabase/.temp/
|
|||||||
# Playwright test results (generated, not source)
|
# Playwright test results (generated, not source)
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
tests/_artifacts/
|
||||||
|
|
||||||
|
# Local debug / smoke scripts (not part of the product)
|
||||||
|
scripts/cycle*-debug.mjs
|
||||||
|
scripts/cycle*-smoke.mjs
|
||||||
|
scripts/cycle*-smoke-session.mjs
|
||||||
|
|
||||||
# IDE / local config
|
# IDE / local config
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
|||||||
@@ -2,7 +2,35 @@
|
|||||||
|
|
||||||
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
|
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
|
||||||
|
|
||||||
**Last updated:** 2026-06 (admin_users schema fix + migration reliability + Google sign-in work)
|
**Last updated:** 2026-06 (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)
|
## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043)
|
||||||
|
|
||||||
@@ -597,3 +625,141 @@ Push `2d55791` fixes two issues that broke the "Start Docker stack" step:
|
|||||||
- `origin/main` (GitHub) — for backup visibility
|
- `origin/main` (GitHub) — for backup visibility
|
||||||
- 36 commits ahead of `origin/main` as of this entry
|
- 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.
|
||||||
|
|||||||
+82
-13
@@ -23,22 +23,70 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { Pool, type PoolClient } from "pg";
|
||||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import * as schema from "./schema";
|
import * as brands from "./schema/brands";
|
||||||
import { getPool, withTx } from "@/lib/db";
|
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;
|
type Schema = typeof schema;
|
||||||
export type Db = NodePgDatabase<Schema>;
|
export type Db = NodePgDatabase<Schema>;
|
||||||
|
|
||||||
/**
|
let _pool: Pool | null = null;
|
||||||
* The Drizzle layer reuses the shared `pg` `Pool` from `@/lib/db` so the
|
|
||||||
* app connects to Postgres through a single pool. Connection settings
|
function getPool(): Pool {
|
||||||
* (`PG_POOL_MAX`, `PG_POOL_IDLE_MS`, `PG_POOL_CONN_TIMEOUT_MS`,
|
if (_pool) return _pool;
|
||||||
* `DATABASE_URL`) live in one place. See `src/lib/db.ts` for the config.
|
const connectionString = process.env.DATABASE_URL;
|
||||||
*
|
if (!connectionString) {
|
||||||
* The `withBrand` and `withPlatformAdmin` helpers reuse `withTx` from
|
throw new Error(
|
||||||
* `@/lib/db` so the BEGIN/COMMIT/ROLLBACK handling has a single home.
|
"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
|
* Run `fn` with a Drizzle client. No brand context is set — the caller
|
||||||
@@ -67,7 +115,7 @@ export async function withBrand<T>(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
fn: (db: Db) => Promise<T>,
|
fn: (db: Db) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return withTx(async (client) => {
|
return runInTransaction(async (client) => {
|
||||||
// set_config(setting, value, is_local) — is_local=true makes it
|
// set_config(setting, value, is_local) — is_local=true makes it
|
||||||
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never
|
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never
|
||||||
// leaks across pooled connections.
|
// leaks across pooled connections.
|
||||||
@@ -87,7 +135,7 @@ export async function withBrand<T>(
|
|||||||
export async function withPlatformAdmin<T>(
|
export async function withPlatformAdmin<T>(
|
||||||
fn: (db: Db) => Promise<T>,
|
fn: (db: Db) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return withTx(async (client) => {
|
return runInTransaction(async (client) => {
|
||||||
await client.query("SELECT set_config('app.current_brand_id', '', true)");
|
await client.query("SELECT set_config('app.current_brand_id', '', true)");
|
||||||
await client.query("SELECT set_config('app.platform_admin', 'true', true)");
|
await client.query("SELECT set_config('app.platform_admin', 'true', true)");
|
||||||
const db = drizzle(client, { schema });
|
const db = drizzle(client, { schema });
|
||||||
@@ -95,4 +143,25 @@ export async function withPlatformAdmin<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 };
|
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()
|
||||||
|
);
|
||||||
@@ -1764,7 +1764,7 @@ DROP POLICY IF EXISTS tenant_isolation ON changelogs;
|
|||||||
CREATE POLICY tenant_isolation ON changelogs FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL);
|
CREATE POLICY tenant_isolation ON changelogs FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL);
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation ON changelog_reads;
|
DROP POLICY IF EXISTS tenant_isolation ON changelog_reads;
|
||||||
CREATE POLICY tenant_isolation ON changelog_reads FOR ALL USING (true) WITH CHECK (true);
|
CREATE POLICY tenant_isolation ON changelog_reads FOR ALL USING (user_id = auth.uid() OR is_platform_admin()) WITH CHECK (user_id = auth.uid() OR is_platform_admin());
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation ON onboarding_progress;
|
DROP POLICY IF EXISTS tenant_isolation ON onboarding_progress;
|
||||||
CREATE POLICY tenant_isolation ON onboarding_progress FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL);
|
CREATE POLICY tenant_isolation ON onboarding_progress FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL);
|
||||||
|
|||||||
@@ -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,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,443 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 0098_time_tracking_timesheets.sql
|
||||||
|
--
|
||||||
|
-- Time Tracking — Phase 2 (Timesheets, GPS, Approval/Lock, Audit)
|
||||||
|
--
|
||||||
|
-- This migration extends the existing time-tracking schema with the
|
||||||
|
-- production / payroll features the field app + admin UI need:
|
||||||
|
--
|
||||||
|
-- 1. New roles on `field_workers` — `driver`, `supervisor`
|
||||||
|
-- (kept distinct from `worker` so admin UIs can filter / grant
|
||||||
|
-- approval rights without expanding the existing role semantics).
|
||||||
|
--
|
||||||
|
-- 2. GPS verification on every clock-in / clock-out —
|
||||||
|
-- `time_tracking_logs` gains lat/lng/accuracy columns for both
|
||||||
|
-- events, plus a boolean `gps_verified` (false when the device
|
||||||
|
-- denied geolocation, returned an error, or low-accuracy fix).
|
||||||
|
--
|
||||||
|
-- 3. Manual time entries —
|
||||||
|
-- `time_tracking_logs.entry_kind` becomes `'clock' | 'manual'`.
|
||||||
|
-- Manual rows carry `manual_reason` (the why-this-was-added text
|
||||||
|
-- required by payroll / labor law) and can be edited freely until
|
||||||
|
-- the parent timesheet is locked.
|
||||||
|
--
|
||||||
|
-- 4. Edit audit fields on `time_tracking_logs` —
|
||||||
|
-- `edited_at`, `edited_by` (admin_user id), `edit_reason`. Filled
|
||||||
|
-- by every edit that lands on a row; never cleared.
|
||||||
|
--
|
||||||
|
-- 5. NEW: `time_tracking_timesheets` — one row per (worker, pay
|
||||||
|
-- period). Statuses drive the approval workflow:
|
||||||
|
-- draft → submitted → approved (locked) → unlocked (admin only,
|
||||||
|
-- audit note required)
|
||||||
|
-- → draft (after rejection)
|
||||||
|
-- When `locked_at IS NOT NULL` the underlying logs become
|
||||||
|
-- read-only to anyone except `platform_admin` / `time_admin`.
|
||||||
|
--
|
||||||
|
-- 6. NEW: `time_tracking_audit_log` — append-only audit trail for
|
||||||
|
-- every mutating action on a timesheet or its entries. Every
|
||||||
|
-- server-action call that mutates a timesheet or a log row MUST
|
||||||
|
-- write a row here in the same transaction.
|
||||||
|
--
|
||||||
|
-- 7. NEW: `time_tracking_supervisor_assignments` — many-to-many
|
||||||
|
-- (supervisor_worker_id, supervisee_worker_id). Supervisors only
|
||||||
|
-- see / approve timesheets for their assigned crew.
|
||||||
|
--
|
||||||
|
-- The migration is idempotent — every ALTER / CREATE uses IF NOT EXISTS
|
||||||
|
-- or a DO block, so re-running on a partially-applied DB is safe.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 1. Role enum extension
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- Existing enum (migration 0097) is named `field_workers_role_check`
|
||||||
|
-- via a CHECK constraint, NOT a Postgres ENUM type. Verify by introspecting
|
||||||
|
-- pg_constraint before adding new values.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
constraint_def TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Fetch the current CHECK expression
|
||||||
|
SELECT pg_get_constraintdef(oid)
|
||||||
|
INTO constraint_def
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'field_workers_role_check';
|
||||||
|
|
||||||
|
IF constraint_def IS NOT NULL THEN
|
||||||
|
-- Drop and recreate with the expanded vocabulary. The set is the union
|
||||||
|
-- of all six role values the codebase now recognises.
|
||||||
|
EXECUTE 'ALTER TABLE field_workers DROP CONSTRAINT field_workers_role_check';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE field_workers
|
||||||
|
ADD CONSTRAINT field_workers_role_check
|
||||||
|
CHECK (role IN ('worker', 'time_admin', 'irrigator', 'water_admin', 'driver', 'supervisor'));
|
||||||
|
|
||||||
|
-- Drop & recreate the Drizzle CHECK by hand if it exists under a different name
|
||||||
|
-- (drizzle-kit may have generated `field_workers_role_check1`).
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
cname TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR cname IN
|
||||||
|
SELECT conname
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid = 'field_workers'::regclass
|
||||||
|
AND contype = 'c'
|
||||||
|
AND pg_get_constraintdef(oid) LIKE '%role%'
|
||||||
|
AND conname <> 'field_workers_role_check'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE field_workers DROP CONSTRAINT %I', cname);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 2. GPS columns + entry_kind + manual_reason + edit audit on logs
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE time_tracking_logs
|
||||||
|
ADD COLUMN IF NOT EXISTS clock_in_lat DOUBLE PRECISION,
|
||||||
|
ADD COLUMN IF NOT EXISTS clock_in_lng DOUBLE PRECISION,
|
||||||
|
ADD COLUMN IF NOT EXISTS clock_in_accuracy_m DOUBLE PRECISION,
|
||||||
|
ADD COLUMN IF NOT EXISTS clock_out_lat DOUBLE PRECISION,
|
||||||
|
ADD COLUMN IF NOT EXISTS clock_out_lng DOUBLE PRECISION,
|
||||||
|
ADD COLUMN IF NOT EXISTS clock_out_accuracy_m DOUBLE PRECISION,
|
||||||
|
ADD COLUMN IF NOT EXISTS gps_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS entry_kind TEXT NOT NULL DEFAULT 'clock',
|
||||||
|
ADD COLUMN IF NOT EXISTS manual_reason TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS edited_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS edit_reason TEXT;
|
||||||
|
|
||||||
|
-- entry_kind CHECK (idempotent)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'time_tracking_logs_entry_kind_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE time_tracking_logs
|
||||||
|
ADD CONSTRAINT time_tracking_logs_entry_kind_check
|
||||||
|
CHECK (entry_kind IN ('clock', 'manual'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Index for fast "all manual entries this period" queries used by payroll
|
||||||
|
CREATE INDEX IF NOT EXISTS time_tracking_logs_brand_kind_idx
|
||||||
|
ON time_tracking_logs (brand_id, entry_kind, clock_in);
|
||||||
|
|
||||||
|
-- Index for the approval queue (entries that belong to a timesheet status)
|
||||||
|
CREATE INDEX IF NOT EXISTS time_tracking_logs_clock_in_idx
|
||||||
|
ON time_tracking_logs (clock_in);
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 3. time_tracking_timesheets (the workflow entity)
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS time_tracking_timesheets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE,
|
||||||
|
period_start DATE NOT NULL,
|
||||||
|
period_end DATE NOT NULL,
|
||||||
|
|
||||||
|
-- Workflow state
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
|
||||||
|
-- Submission
|
||||||
|
submitted_at TIMESTAMPTZ,
|
||||||
|
submitted_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||||
|
submitted_notes TEXT,
|
||||||
|
|
||||||
|
-- Approval (locks the timesheet)
|
||||||
|
reviewed_at TIMESTAMPTZ,
|
||||||
|
reviewed_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||||
|
reviewer_comment TEXT,
|
||||||
|
|
||||||
|
-- Rejection (sends back to draft)
|
||||||
|
rejected_at TIMESTAMPTZ,
|
||||||
|
rejected_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||||
|
rejection_reason TEXT,
|
||||||
|
|
||||||
|
-- Lock state (mirrors reviewed_at for fast filtering)
|
||||||
|
locked_at TIMESTAMPTZ,
|
||||||
|
locked_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Unlock state (rare, admin-only, MUST carry a note)
|
||||||
|
unlocked_at TIMESTAMPTZ,
|
||||||
|
unlocked_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||||
|
unlock_audit_note TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
-- Pre-computed totals — recomputed by triggers / RPCs, but cached here
|
||||||
|
-- so the timesheet list view doesn't re-aggregate on every render.
|
||||||
|
total_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
regular_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
overtime_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
break_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
entry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Daily-OT flash cache: JSONB { 'YYYY-MM-DD': minutes }
|
||||||
|
daily_totals JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT time_tracking_timesheets_period_check
|
||||||
|
CHECK (period_end >= period_start),
|
||||||
|
CONSTRAINT time_tracking_timesheets_status_check
|
||||||
|
CHECK (status IN ('draft', 'submitted', 'approved', 'rejected'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS time_tracking_timesheets_worker_period_uniq
|
||||||
|
ON time_tracking_timesheets (field_worker_id, period_start, period_end);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS time_tracking_timesheets_brand_status_idx
|
||||||
|
ON time_tracking_timesheets (brand_id, status, period_start DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS time_tracking_timesheets_brand_worker_idx
|
||||||
|
ON time_tracking_timesheets (brand_id, field_worker_id);
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 4. time_tracking_audit_log (append-only)
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS time_tracking_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) ON DELETE SET NULL,
|
||||||
|
actor_label TEXT NOT NULL,
|
||||||
|
actor_kind TEXT NOT NULL DEFAULT 'admin',
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id UUID,
|
||||||
|
details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS time_tracking_audit_log_brand_recent_idx
|
||||||
|
ON time_tracking_audit_log (brand_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS time_tracking_audit_log_entity_idx
|
||||||
|
ON time_tracking_audit_log (entity_type, entity_id);
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 5. Supervisor ↔ supervisee assignments
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS time_tracking_supervisor_assignments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
supervisor_field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE,
|
||||||
|
supervisee_field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT time_tracking_supervisor_assignments_no_self
|
||||||
|
CHECK (supervisor_field_worker_id <> supervisee_field_worker_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS time_tracking_supervisor_assignments_uniq
|
||||||
|
ON time_tracking_supervisor_assignments (
|
||||||
|
supervisor_field_worker_id, supervisee_field_worker_id
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS time_tracking_supervisor_assignments_supervisor_idx
|
||||||
|
ON time_tracking_supervisor_assignments (supervisor_field_worker_id);
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 6. RLS — minimal, since the platform already relies on app-layer
|
||||||
|
-- brand scoping via SECURITY DEFINER / Drizzle `withBrand` wrappers.
|
||||||
|
-- The new tables are added to the same RLS scaffolding as the existing
|
||||||
|
-- `time_tracking_*` tables when present; otherwise left open at the
|
||||||
|
-- table level (the app enforces isolation).
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
rls_enabled BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
-- If RLS is enabled on time_tracking_logs, mirror it on the new tables.
|
||||||
|
SELECT relrowsecurity INTO rls_enabled
|
||||||
|
FROM pg_class
|
||||||
|
WHERE relname = 'time_tracking_logs';
|
||||||
|
|
||||||
|
IF rls_enabled THEN
|
||||||
|
EXECUTE 'ALTER TABLE time_tracking_timesheets ENABLE ROW LEVEL SECURITY';
|
||||||
|
EXECUTE 'ALTER TABLE time_tracking_audit_log ENABLE ROW LEVEL SECURITY';
|
||||||
|
EXECUTE 'ALTER TABLE time_tracking_supervisor_assignments ENABLE ROW LEVEL SECURITY';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 7. Triggers — keep `updated_at` fresh
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION time_tracking_set_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS time_tracking_timesheets_set_updated_at
|
||||||
|
ON time_tracking_timesheets;
|
||||||
|
CREATE TRIGGER time_tracking_timesheets_set_updated_at
|
||||||
|
BEFORE UPDATE ON time_tracking_timesheets
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION time_tracking_set_updated_at();
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 8. Helper view — admin "what's pending" dashboard
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW time_tracking_approval_queue AS
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.brand_id,
|
||||||
|
t.field_worker_id,
|
||||||
|
fw.name AS worker_name,
|
||||||
|
t.period_start,
|
||||||
|
t.period_end,
|
||||||
|
t.status,
|
||||||
|
t.total_minutes,
|
||||||
|
t.regular_minutes,
|
||||||
|
t.overtime_minutes,
|
||||||
|
t.entry_count,
|
||||||
|
t.submitted_at,
|
||||||
|
t.submitted_notes,
|
||||||
|
t.reviewed_at,
|
||||||
|
t.locked_at
|
||||||
|
FROM time_tracking_timesheets t
|
||||||
|
JOIN field_workers fw ON fw.id = t.field_worker_id;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 9. SECURITY DEFINER RPC — recompute timesheet totals
|
||||||
|
-- Called by the server after any entry add/edit/delete so the cached
|
||||||
|
-- totals on `time_tracking_timesheets` stay in sync with the underlying
|
||||||
|
-- `time_tracking_logs`.
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION recompute_timesheet_totals(p_timesheet_id UUID)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_brand_id UUID;
|
||||||
|
v_worker_id UUID;
|
||||||
|
v_period_start DATE;
|
||||||
|
v_period_end DATE;
|
||||||
|
v_total INTEGER;
|
||||||
|
v_regular INTEGER;
|
||||||
|
v_ot INTEGER;
|
||||||
|
v_break INTEGER;
|
||||||
|
v_count INTEGER;
|
||||||
|
v_daily JSONB;
|
||||||
|
v_daily_thresh NUMERIC;
|
||||||
|
v_settings_brand UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Load the timesheet header
|
||||||
|
SELECT brand_id, field_worker_id, period_start, period_end
|
||||||
|
INTO v_brand_id, v_worker_id, v_period_start, v_period_end
|
||||||
|
FROM time_tracking_timesheets
|
||||||
|
WHERE id = p_timesheet_id;
|
||||||
|
|
||||||
|
IF v_brand_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Pull daily OT threshold from settings (default 8h)
|
||||||
|
SELECT COALESCE(daily_overtime_threshold, 8)
|
||||||
|
INTO v_daily_thresh
|
||||||
|
FROM time_tracking_settings
|
||||||
|
WHERE brand_id = v_brand_id;
|
||||||
|
v_daily_thresh := COALESCE(v_daily_thresh, 8);
|
||||||
|
|
||||||
|
-- Aggregate underlying logs
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(
|
||||||
|
GREATEST(0,
|
||||||
|
EXTRACT(EPOCH FROM (COALESCE(clock_out, NOW()) - clock_in)) / 60
|
||||||
|
- COALESCE(lunch_break_minutes, 0)
|
||||||
|
)
|
||||||
|
), 0)::INTEGER,
|
||||||
|
COALESCE(SUM(lunch_break_minutes), 0)::INTEGER,
|
||||||
|
COUNT(*)::INTEGER,
|
||||||
|
jsonb_object_agg(
|
||||||
|
d::text, mins
|
||||||
|
) FILTER (WHERE d IS NOT NULL)
|
||||||
|
INTO v_total, v_break, v_count, v_daily
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
(clock_in AT TIME ZONE 'America/Denver')::date AS d,
|
||||||
|
SUM(
|
||||||
|
GREATEST(0,
|
||||||
|
EXTRACT(EPOCH FROM (COALESCE(clock_out, NOW()) - clock_in)) / 60
|
||||||
|
- COALESCE(lunch_break_minutes, 0)
|
||||||
|
)
|
||||||
|
) AS mins
|
||||||
|
FROM time_tracking_logs
|
||||||
|
WHERE brand_id = v_brand_id
|
||||||
|
AND field_worker_id = v_worker_id
|
||||||
|
AND clock_in >= v_period_start
|
||||||
|
AND clock_in < (v_period_end + INTERVAL '1 day')
|
||||||
|
GROUP BY 1
|
||||||
|
) daily_agg
|
||||||
|
RIGHT JOIN time_tracking_logs l
|
||||||
|
ON l.brand_id = v_brand_id
|
||||||
|
AND l.field_worker_id = v_worker_id
|
||||||
|
AND l.clock_in >= v_period_start
|
||||||
|
AND l.clock_in < (v_period_end + INTERVAL '1 day');
|
||||||
|
|
||||||
|
v_daily := COALESCE(v_daily, '{}'::jsonb);
|
||||||
|
|
||||||
|
-- Daily OT = sum across days of (daily_mins - threshold*60)+
|
||||||
|
v_ot := 0;
|
||||||
|
IF jsonb_typeof(v_daily) = 'object' THEN
|
||||||
|
SELECT COALESCE(SUM(GREATEST(0, (v.value::numeric - v_daily_thresh * 60))), 0)::INTEGER
|
||||||
|
INTO v_ot
|
||||||
|
FROM jsonb_each(v_daily) v;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_regular := GREATEST(0, v_total - v_ot);
|
||||||
|
|
||||||
|
UPDATE time_tracking_timesheets
|
||||||
|
SET total_minutes = v_total,
|
||||||
|
regular_minutes = v_regular,
|
||||||
|
overtime_minutes = v_ot,
|
||||||
|
break_minutes = v_break,
|
||||||
|
entry_count = v_count,
|
||||||
|
daily_totals = v_daily
|
||||||
|
WHERE id = p_timesheet_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 10. SECURITY DEFINER RPC — get-or-create a timesheet for a worker × period
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_or_create_timesheet(
|
||||||
|
p_brand_id UUID,
|
||||||
|
p_worker_id UUID,
|
||||||
|
p_period_start DATE,
|
||||||
|
p_period_end DATE
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id UUID;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO v_id
|
||||||
|
FROM time_tracking_timesheets
|
||||||
|
WHERE field_worker_id = p_worker_id
|
||||||
|
AND period_start = p_period_start
|
||||||
|
AND period_end = p_period_end;
|
||||||
|
|
||||||
|
IF v_id IS NULL THEN
|
||||||
|
INSERT INTO time_tracking_timesheets
|
||||||
|
(brand_id, field_worker_id, period_start, period_end)
|
||||||
|
VALUES
|
||||||
|
(p_brand_id, p_worker_id, p_period_start, p_period_end)
|
||||||
|
RETURNING id INTO v_id;
|
||||||
|
|
||||||
|
PERFORM recompute_timesheet_totals(v_id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN v_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 0099_brand_hero_video.sql
|
||||||
|
-- Add an optional hero_video_url column to brand_settings so each brand can
|
||||||
|
-- point its storefront hero at any video URL (CDN, signed S3, Supabase
|
||||||
|
-- storage, etc.) without a code change. When unset or unplayable, the
|
||||||
|
-- storefront hero falls back to hero_image_url.
|
||||||
|
--
|
||||||
|
-- We intentionally avoid touching the `upsert_brand_settings(...)`
|
||||||
|
-- SECURITY DEFINER RPC here — its 32-parameter positional signature is
|
||||||
|
-- already deployed in production. New value writes use a separate
|
||||||
|
-- idempotent statement.
|
||||||
|
|
||||||
|
ALTER TABLE brand_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS hero_video_url TEXT;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN brand_settings.hero_video_url IS
|
||||||
|
'Optional. Direct URL to a hero background video (mp4/webm). When null or 4xx/5xx, storefront falls back to hero_image_url as a still poster.';
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Audit log. Source of truth: `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
jsonb,
|
|
||||||
timestamp,
|
|
||||||
index,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { brands } from "./brands";
|
|
||||||
|
|
||||||
export const auditLog = pgTable(
|
|
||||||
"audit_log",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
brandId: uuid("brand_id").references(() => brands.id, {
|
|
||||||
onDelete: "cascade",
|
|
||||||
}),
|
|
||||||
userId: uuid("user_id"), // FK to neon_auth.user(id) — plain UUID, enforced at DB level
|
|
||||||
action: text("action").notNull(),
|
|
||||||
targetType: text("target_type"),
|
|
||||||
targetId: uuid("target_id"),
|
|
||||||
payload: jsonb("payload"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("audit_log_brand_idx").on(t.brandId, t.createdAt),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type AuditLog = typeof auditLog.$inferSelect;
|
|
||||||
export type NewAuditLog = typeof auditLog.$inferInsert;
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared enums for the SaaS schema. Mirrored in SQL as TEXT + CHECK.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* import { tenantStatusEnum, type TenantStatus } from "@/db/schema/enums";
|
|
||||||
* import { pgEnum } from "drizzle-orm/pg-core";
|
|
||||||
*
|
|
||||||
* export const tenantStatus = pgEnum("tenant_status", tenantStatusEnum);
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const tenantStatusEnum = [
|
|
||||||
"trial",
|
|
||||||
"active",
|
|
||||||
"past_due",
|
|
||||||
"suspended",
|
|
||||||
"churned",
|
|
||||||
] as const;
|
|
||||||
export type TenantStatus = (typeof tenantStatusEnum)[number];
|
|
||||||
|
|
||||||
export const authProviderEnum = ["dev", "google", "email"] as const;
|
|
||||||
export type AuthProvider = (typeof authProviderEnum)[number];
|
|
||||||
|
|
||||||
export const roleEnum = ["platform_admin", "brand_admin", "store_employee"] as const;
|
|
||||||
export type Role = (typeof roleEnum)[number];
|
|
||||||
|
|
||||||
export const planCodeEnum = ["starter", "farm", "enterprise"] as const;
|
|
||||||
export type PlanCode = (typeof planCodeEnum)[number];
|
|
||||||
|
|
||||||
export const addOnCodeEnum = [
|
|
||||||
"wholesale_portal",
|
|
||||||
"harvest_reach",
|
|
||||||
"ai_tools",
|
|
||||||
"water_log",
|
|
||||||
"square_sync",
|
|
||||||
"sms_campaigns",
|
|
||||||
] as const;
|
|
||||||
export type AddOnCode = (typeof addOnCodeEnum)[number];
|
|
||||||
|
|
||||||
export const subscriptionStatusEnum = [
|
|
||||||
"trialing",
|
|
||||||
"active",
|
|
||||||
"past_due",
|
|
||||||
"canceled",
|
|
||||||
"incomplete",
|
|
||||||
] as const;
|
|
||||||
export type SubscriptionStatus = (typeof subscriptionStatusEnum)[number];
|
|
||||||
|
|
||||||
export const addOnStatusEnum = ["active", "canceled"] as const;
|
|
||||||
export type AddOnStatus = (typeof addOnStatusEnum)[number];
|
|
||||||
|
|
||||||
export const stopStatusEnum = ["active", "paused", "closed"] as const;
|
|
||||||
export type StopStatus = (typeof stopStatusEnum)[number];
|
|
||||||
|
|
||||||
export const orderStatusEnum = [
|
|
||||||
"pending",
|
|
||||||
"confirmed",
|
|
||||||
"fulfilled",
|
|
||||||
"canceled",
|
|
||||||
] as const;
|
|
||||||
export type OrderStatus = (typeof orderStatusEnum)[number];
|
|
||||||
|
|
||||||
export const fulfillmentEnum = ["pickup", "ship", "mixed"] as const;
|
|
||||||
export type Fulfillment = (typeof fulfillmentEnum)[number];
|
|
||||||
|
|
||||||
export const itemFulfillmentEnum = ["pickup", "ship"] as const;
|
|
||||||
export type ItemFulfillment = (typeof itemFulfillmentEnum)[number];
|
|
||||||
|
|
||||||
export const campaignStatusEnum = [
|
|
||||||
"draft",
|
|
||||||
"scheduled",
|
|
||||||
"sending",
|
|
||||||
"sent",
|
|
||||||
"canceled",
|
|
||||||
] as const;
|
|
||||||
export type CampaignStatus = (typeof campaignStatusEnum)[number];
|
|
||||||
@@ -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", "driver", "supervisor"],
|
||||||
|
})
|
||||||
|
.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"];
|
||||||
@@ -14,6 +14,7 @@ export * from "./water-log";
|
|||||||
export * from "./communications";
|
export * from "./communications";
|
||||||
export * from "./marketing";
|
export * from "./marketing";
|
||||||
export * from "./time-tracking";
|
export * from "./time-tracking";
|
||||||
|
export * from "./field-workers";
|
||||||
export * from "./shipping";
|
export * from "./shipping";
|
||||||
export * from "./support";
|
export * from "./support";
|
||||||
export * from "./files";
|
export * from "./files";
|
||||||
@@ -10,9 +10,16 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
index,
|
index,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { campaignStatusEnum } from "./enums";
|
|
||||||
import { brands } from "./brands";
|
import { brands } from "./brands";
|
||||||
|
|
||||||
|
const campaignStatusEnum = [
|
||||||
|
"draft",
|
||||||
|
"scheduled",
|
||||||
|
"sending",
|
||||||
|
"sent",
|
||||||
|
"canceled",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const emailTemplates = pgTable(
|
export const emailTemplates = pgTable(
|
||||||
"email_templates",
|
"email_templates",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
+382
-31
@@ -1,5 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Time Tracking. Source: `db/migrations/0001_init.sql`.
|
* 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.
|
||||||
|
*
|
||||||
|
* Cycle 12 (Phase 2): GPS columns + manual entries + audit log +
|
||||||
|
* timesheet approval workflow (0098_time_tracking_timesheets.sql).
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -10,8 +25,12 @@ import {
|
|||||||
boolean,
|
boolean,
|
||||||
timestamp,
|
timestamp,
|
||||||
index,
|
index,
|
||||||
|
jsonb,
|
||||||
|
date,
|
||||||
|
doublePrecision,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { brands } from "./brands";
|
import { brands, adminUsers } from "./brands";
|
||||||
|
import { fieldWorkers } from "./field-workers";
|
||||||
|
|
||||||
export const timeTrackingSettings = pgTable(
|
export const timeTrackingSettings = pgTable(
|
||||||
"time_tracking_settings",
|
"time_tracking_settings",
|
||||||
@@ -49,29 +68,12 @@ export const timeTrackingSettings = pgTable(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const timeTrackingWorkers = pgTable(
|
// Cycle 10: `time_tracking_workers` table was DROPPED in
|
||||||
"time_tracking_workers",
|
// 0097_field_workers.sql. Worker records now live in `field_workers`
|
||||||
{
|
// (see `db/schema/field-workers.ts`). Any code that previously read
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
// from timeTrackingWorkers should now read from fieldWorkers and
|
||||||
brandId: uuid("brand_id")
|
// filter by role = 'worker' or 'time_admin' if it needs time-only
|
||||||
.notNull()
|
// workers.
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
role: text("role", { enum: ["worker", "time_admin"] })
|
|
||||||
.notNull()
|
|
||||||
.default("worker"),
|
|
||||||
lang: text("lang").notNull().default("en"),
|
|
||||||
pin: text("pin").notNull(),
|
|
||||||
active: boolean("active").notNull().default(true),
|
|
||||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("time_tracking_workers_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const timeTrackingTasks = pgTable(
|
export const timeTrackingTasks = pgTable(
|
||||||
"time_tracking_tasks",
|
"time_tracking_tasks",
|
||||||
@@ -103,9 +105,10 @@ export const timeTrackingLogs = pgTable(
|
|||||||
brandId: uuid("brand_id")
|
brandId: uuid("brand_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
workerId: uuid("worker_id")
|
/** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */
|
||||||
|
fieldWorkerId: uuid("field_worker_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => timeTrackingWorkers.id, { onDelete: "cascade" }),
|
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||||
taskId: uuid("task_id").references(() => timeTrackingTasks.id, {
|
taskId: uuid("task_id").references(() => timeTrackingTasks.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
@@ -117,14 +120,47 @@ export const timeTrackingLogs = pgTable(
|
|||||||
submittedVia: text("submitted_via", {
|
submittedVia: text("submitted_via", {
|
||||||
enum: ["manual", "field", "import"],
|
enum: ["manual", "field", "import"],
|
||||||
}).notNull().default("manual"),
|
}).notNull().default("manual"),
|
||||||
|
|
||||||
|
// ── Migration 0098: GPS + manual entries + edit audit ────────
|
||||||
|
/** `'clock'` = from the field app, `'manual'` = admin-entered. */
|
||||||
|
entryKind: text("entry_kind", { enum: ["clock", "manual"] })
|
||||||
|
.notNull()
|
||||||
|
.default("clock"),
|
||||||
|
/** Required when `entry_kind = 'manual'`. Surfaced in payroll exports. */
|
||||||
|
manualReason: text("manual_reason"),
|
||||||
|
/** GPS columns — captured at clock-in / clock-out. Nullable: device
|
||||||
|
* may have denied geolocation, in which case `gpsVerified = false`. */
|
||||||
|
clockInLat: doublePrecision("clock_in_lat"),
|
||||||
|
clockInLng: doublePrecision("clock_in_lng"),
|
||||||
|
clockInAccuracyM: doublePrecision("clock_in_accuracy_m"),
|
||||||
|
clockOutLat: doublePrecision("clock_out_lat"),
|
||||||
|
clockOutLng: doublePrecision("clock_out_lng"),
|
||||||
|
clockOutAccuracyM: doublePrecision("clock_out_accuracy_m"),
|
||||||
|
/** True iff the device supplied a fix with accuracy ≤ 100m. */
|
||||||
|
gpsVerified: boolean("gps_verified").notNull().default(false),
|
||||||
|
/** Edit audit — filled every time a server action updates the row
|
||||||
|
* after creation. Never cleared. */
|
||||||
|
editedAt: timestamp("edited_at", { withTimezone: true }),
|
||||||
|
editedBy: uuid("edited_by").references(() => adminUsers.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
editReason: text("edit_reason"),
|
||||||
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
brandIdx: index("time_tracking_logs_brand_idx").on(t.brandId),
|
brandIdx: index("time_tracking_logs_brand_idx").on(t.brandId),
|
||||||
workerIdx: index("time_tracking_logs_worker_idx").on(t.workerId),
|
fieldWorkerIdx: index("time_tracking_logs_field_worker_idx").on(
|
||||||
|
t.fieldWorkerId,
|
||||||
|
),
|
||||||
clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn),
|
clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn),
|
||||||
|
brandKindIdx: index("time_tracking_logs_brand_kind_idx").on(
|
||||||
|
t.brandId,
|
||||||
|
t.entryKind,
|
||||||
|
t.clockIn,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -135,8 +171,9 @@ export const timeTrackingNotificationLog = pgTable(
|
|||||||
brandId: uuid("brand_id")
|
brandId: uuid("brand_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
workerId: uuid("worker_id").references(
|
/** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */
|
||||||
() => timeTrackingWorkers.id,
|
fieldWorkerId: uuid("field_worker_id").references(
|
||||||
|
() => fieldWorkers.id,
|
||||||
{ onDelete: "set null" },
|
{ onDelete: "set null" },
|
||||||
),
|
),
|
||||||
notificationType: text("notification_type").notNull(),
|
notificationType: text("notification_type").notNull(),
|
||||||
@@ -153,9 +190,323 @@ export const timeTrackingNotificationLog = pgTable(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Migration 0098: Timesheet + approval workflow ─────────────────────────
|
||||||
|
|
||||||
|
export const TIMESHEET_STATUSES = [
|
||||||
|
"draft",
|
||||||
|
"submitted",
|
||||||
|
"approved",
|
||||||
|
"rejected",
|
||||||
|
] as const;
|
||||||
|
export type TimesheetStatus = (typeof TIMESHEET_STATUSES)[number];
|
||||||
|
|
||||||
|
export const timeTrackingTimesheets = pgTable(
|
||||||
|
"time_tracking_timesheets",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
brandId: uuid("brand_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
|
fieldWorkerId: uuid("field_worker_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||||
|
periodStart: date("period_start").notNull(),
|
||||||
|
periodEnd: date("period_end").notNull(),
|
||||||
|
|
||||||
|
status: text("status", { enum: TIMESHEET_STATUSES })
|
||||||
|
.notNull()
|
||||||
|
.default("draft"),
|
||||||
|
|
||||||
|
// Submission
|
||||||
|
submittedAt: timestamp("submitted_at", { withTimezone: true }),
|
||||||
|
submittedBy: uuid("submitted_by").references(() => adminUsers.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
submittedNotes: text("submitted_notes"),
|
||||||
|
|
||||||
|
// Approval (locks the timesheet)
|
||||||
|
reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
|
||||||
|
reviewedBy: uuid("reviewed_by").references(() => adminUsers.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
reviewerComment: text("reviewer_comment"),
|
||||||
|
|
||||||
|
// Rejection
|
||||||
|
rejectedAt: timestamp("rejected_at", { withTimezone: true }),
|
||||||
|
rejectedBy: uuid("rejected_by").references(() => adminUsers.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
rejectionReason: text("rejection_reason"),
|
||||||
|
|
||||||
|
// Lock mirror (always equals reviewed_at when status=approved)
|
||||||
|
lockedAt: timestamp("locked_at", { withTimezone: true }),
|
||||||
|
lockedBy: uuid("locked_by").references(() => adminUsers.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Unlock (rare, admin-only, MUST carry an audit note)
|
||||||
|
unlockedAt: timestamp("unlocked_at", { withTimezone: true }),
|
||||||
|
unlockedBy: uuid("unlocked_by").references(() => adminUsers.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
unlockAuditNote: text("unlock_audit_note").notNull().default(""),
|
||||||
|
|
||||||
|
// Pre-computed totals (recomputed by recompute_timesheet_totals RPC)
|
||||||
|
totalMinutes: integer("total_minutes").notNull().default(0),
|
||||||
|
regularMinutes: integer("regular_minutes").notNull().default(0),
|
||||||
|
overtimeMinutes: integer("overtime_minutes").notNull().default(0),
|
||||||
|
breakMinutes: integer("break_minutes").notNull().default(0),
|
||||||
|
entryCount: integer("entry_count").notNull().default(0),
|
||||||
|
/** Map of date string ("YYYY-MM-DD") → minutes worked that day. */
|
||||||
|
dailyTotals: jsonb("daily_totals").notNull().default({}),
|
||||||
|
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
workerPeriodUniq: index(
|
||||||
|
"time_tracking_timesheets_worker_period_uniq",
|
||||||
|
).on(t.fieldWorkerId, t.periodStart, t.periodEnd),
|
||||||
|
brandStatusIdx: index("time_tracking_timesheets_brand_status_idx").on(
|
||||||
|
t.brandId,
|
||||||
|
t.status,
|
||||||
|
t.periodStart,
|
||||||
|
),
|
||||||
|
brandWorkerIdx: index("time_tracking_timesheets_brand_worker_idx").on(
|
||||||
|
t.brandId,
|
||||||
|
t.fieldWorkerId,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Append-only audit log — every mutating action on a timesheet or its
|
||||||
|
* underlying logs MUST write a row here in the same DB transaction. */
|
||||||
|
export const timeTrackingAuditLog = pgTable(
|
||||||
|
"time_tracking_audit_log",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
brandId: uuid("brand_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
|
/** Admin user (or null when the actor was the worker themselves). */
|
||||||
|
actorId: uuid("actor_id").references(() => adminUsers.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
actorLabel: text("actor_label").notNull(),
|
||||||
|
actorKind: text("actor_kind").notNull().default("admin"),
|
||||||
|
action: text("action").notNull(),
|
||||||
|
entityType: text("entity_type").notNull(),
|
||||||
|
entityId: uuid("entity_id"),
|
||||||
|
details: jsonb("details").notNull().default({}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
brandRecentIdx: index("time_tracking_audit_log_brand_recent_idx").on(
|
||||||
|
t.brandId,
|
||||||
|
t.createdAt,
|
||||||
|
),
|
||||||
|
entityIdx: index("time_tracking_audit_log_entity_idx").on(
|
||||||
|
t.entityType,
|
||||||
|
t.entityId,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Supervisor ↔ supervisee edges. The brand admin assigns edges; the
|
||||||
|
* approval queue filters by them. */
|
||||||
|
export const timeTrackingSupervisorAssignments = pgTable(
|
||||||
|
"time_tracking_supervisor_assignments",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
brandId: uuid("brand_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
|
supervisorFieldWorkerId: uuid("supervisor_field_worker_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||||
|
superviseeFieldWorkerId: uuid("supervisee_field_worker_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
uniq: index("time_tracking_supervisor_assignments_uniq").on(
|
||||||
|
t.supervisorFieldWorkerId,
|
||||||
|
t.superviseeFieldWorkerId,
|
||||||
|
),
|
||||||
|
supervisorIdx: index(
|
||||||
|
"time_tracking_supervisor_assignments_supervisor_idx",
|
||||||
|
).on(t.supervisorFieldWorkerId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect;
|
export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect;
|
||||||
export type TimeTrackingWorker = typeof timeTrackingWorkers.$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 TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
|
||||||
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
|
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
|
||||||
export type TimeTrackingNotificationLog =
|
export type TimeTrackingNotificationLog =
|
||||||
typeof timeTrackingNotificationLog.$inferSelect;
|
typeof timeTrackingNotificationLog.$inferSelect;
|
||||||
|
export type TimeTrackingTimesheet = typeof timeTrackingTimesheets.$inferSelect;
|
||||||
|
export type TimeTrackingTimesheetInsert =
|
||||||
|
typeof timeTrackingTimesheets.$inferInsert;
|
||||||
|
export type TimeTrackingAuditLogEntry =
|
||||||
|
typeof timeTrackingAuditLog.$inferSelect;
|
||||||
|
export type TimeTrackingSupervisorAssignment =
|
||||||
|
typeof timeTrackingSupervisorAssignments.$inferSelect;
|
||||||
|
|
||||||
|
// ── Inferred types ──
|
||||||
|
|
||||||
|
// 0098 — daily totals JSON shape stored on the timesheet
|
||||||
|
export type DailyTotals = Record<string, number>;
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
+198
-36
@@ -3,7 +3,8 @@
|
|||||||
*
|
*
|
||||||
* Six tables, all brand-scoped with RLS:
|
* Six tables, all brand-scoped with RLS:
|
||||||
* - water_headgates — physical gates a measurement is tied to
|
* - water_headgates — physical gates a measurement is tied to
|
||||||
* - water_irrigators — PIN-authenticated field workers
|
* - field_workers — unified worker table (cycle 10; replaces
|
||||||
|
* water_irrigators + time_tracking_workers)
|
||||||
* - water_sessions — short-lived PIN sessions for irrigators
|
* - water_sessions — short-lived PIN sessions for irrigators
|
||||||
* - water_log_entries — the actual reading logs
|
* - water_log_entries — the actual reading logs
|
||||||
* - water_alert_log — high/low threshold alert history
|
* - water_alert_log — high/low threshold alert history
|
||||||
@@ -24,10 +25,15 @@ import {
|
|||||||
index,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
integer,
|
integer,
|
||||||
check,
|
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { brands } from "./brands";
|
import { brands } from "./brands";
|
||||||
import { adminUsers } 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(
|
export const waterHeadgates = pgTable(
|
||||||
"water_headgates",
|
"water_headgates",
|
||||||
@@ -62,40 +68,21 @@ export const waterHeadgates = pgTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const waterIrrigators = pgTable(
|
// Cycle 10: water_irrigators table was DROPPED in
|
||||||
"water_irrigators",
|
// 0097_field_workers.sql. Worker records now live in
|
||||||
{
|
// `field_workers` (see `db/schema/field-workers.ts`).
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
// Any code that previously read from waterIrrigators should now
|
||||||
brandId: uuid("brand_id")
|
// read from fieldWorkers and filter by role = 'irrigator' or
|
||||||
.notNull()
|
// 'water_admin' if it needs water-only workers.
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
pinHash: text("pin_hash").notNull(),
|
|
||||||
languagePreference: text("language_preference")
|
|
||||||
.notNull()
|
|
||||||
.default("en"),
|
|
||||||
/** "irrigator" submits entries only, "water_admin" can manage the brand. */
|
|
||||||
role: text("role").notNull().default("irrigator"),
|
|
||||||
phone: text("phone"),
|
|
||||||
notes: text("notes"),
|
|
||||||
active: boolean("active").notNull().default(true),
|
|
||||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
brandIdx: index("water_irrigators_brand_idx").on(t.brandId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const waterSessions = pgTable(
|
export const waterSessions = pgTable(
|
||||||
"water_sessions",
|
"water_sessions",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
irrigatorId: uuid("irrigator_id")
|
/** Cycle 10: was `irrigator_id` (FK → water_irrigators); now unified. */
|
||||||
|
fieldWorkerId: uuid("field_worker_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
|
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -113,9 +100,10 @@ export const waterLogEntries = pgTable(
|
|||||||
headgateId: uuid("headgate_id")
|
headgateId: uuid("headgate_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => waterHeadgates.id, { onDelete: "cascade" }),
|
.references(() => waterHeadgates.id, { onDelete: "cascade" }),
|
||||||
irrigatorId: uuid("irrigator_id")
|
/** Cycle 10: was `irrigator_id` (FK → water_irrigators); now unified. */
|
||||||
|
fieldWorkerId: uuid("field_worker_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
|
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||||
/** Raw measurement value, in the entry's `unit`. */
|
/** Raw measurement value, in the entry's `unit`. */
|
||||||
measurement: numeric("measurement").notNull(),
|
measurement: numeric("measurement").notNull(),
|
||||||
unit: text("unit").notNull(),
|
unit: text("unit").notNull(),
|
||||||
@@ -142,8 +130,8 @@ export const waterLogEntries = pgTable(
|
|||||||
t.brandId,
|
t.brandId,
|
||||||
t.loggedDate,
|
t.loggedDate,
|
||||||
),
|
),
|
||||||
irrigatorIdx: index("water_log_entries_irrigator_idx").on(
|
fieldWorkerIdx: index("water_log_entries_field_worker_idx").on(
|
||||||
t.irrigatorId,
|
t.fieldWorkerId,
|
||||||
t.loggedAt,
|
t.loggedAt,
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
@@ -244,8 +232,9 @@ export const waterAuditLog = pgTable(
|
|||||||
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
|
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
|
||||||
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
|
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
|
||||||
|
|
||||||
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
|
// Cycle 10: `WaterIrrigator` / `WaterIrrigatorInsert` were removed.
|
||||||
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
|
// 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 WaterSession = typeof waterSessions.$inferSelect;
|
||||||
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
|
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
|
||||||
@@ -255,3 +244,176 @@ export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
|
|||||||
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
|
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
|
||||||
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
|
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
|
||||||
export type WaterAuditLog = typeof waterAuditLog.$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,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;
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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`.
|
||||||
+15
-11
@@ -34,6 +34,12 @@ const nextConfig: NextConfig = {
|
|||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "picsum.photos",
|
hostname: "picsum.photos",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Brand media hosted on the crispygoat MinIO bucket
|
||||||
|
// (e.g. s3.crispygoat.com/videos/wp-import/...)
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "s3.crispygoat.com",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
formats: ["image/avif", "image/webp"],
|
formats: ["image/avif", "image/webp"],
|
||||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||||
@@ -124,17 +130,6 @@ const nextConfig: NextConfig = {
|
|||||||
{ source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false },
|
{ source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false },
|
||||||
{ source: "/admin/stops", destination: "/admin/v2/stops", permanent: false },
|
{ source: "/admin/stops", destination: "/admin/v2/stops", permanent: false },
|
||||||
{ source: "/admin/products", destination: "/admin/v2/products", permanent: false },
|
{ source: "/admin/products", destination: "/admin/v2/products", permanent: false },
|
||||||
// v1 admin pages that hit the dead supabase shim. There are no v2
|
|
||||||
// equivalents yet (reports / taxes / settings sub-pages are still
|
|
||||||
// TBD on the v2 surface), so they redirect to the v2 dashboard
|
|
||||||
// for now. Once those modules ship on v2, the redirects can be
|
|
||||||
// tightened to point at the new pages.
|
|
||||||
{ source: "/admin/products/:id", destination: "/admin/v2/products", permanent: false },
|
|
||||||
{ source: "/admin/reports", destination: "/admin/v2", permanent: false },
|
|
||||||
{ source: "/admin/taxes", destination: "/admin/v2", permanent: false },
|
|
||||||
{ source: "/admin/settings/shipping", destination: "/admin/settings", permanent: false },
|
|
||||||
{ source: "/admin/settings/integrations", destination: "/admin/settings", permanent: false },
|
|
||||||
{ source: "/admin/settings/billing", destination: "/admin/settings", permanent: false },
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -170,6 +165,15 @@ const nextConfig: NextConfig = {
|
|||||||
fullUrl: process.env.NODE_ENV === "development",
|
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;
|
export default nextConfig;
|
||||||
+2
-11
@@ -28,16 +28,12 @@
|
|||||||
"@aws-sdk/client-s3": "^3.1064.0",
|
"@aws-sdk/client-s3": "^3.1064.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1064.0",
|
"@aws-sdk/s3-request-presigner": "^3.1064.0",
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@gsap/react": "^2.1.2",
|
|
||||||
"@neondatabase/auth": "^0.4.2-beta",
|
"@neondatabase/auth": "^0.4.2-beta",
|
||||||
"@neondatabase/serverless": "^1.1.0",
|
"@neondatabase/serverless": "^1.1.0",
|
||||||
"@sentry/nextjs": "^10.55.0",
|
"@sentry/nextjs": "^10.55.0",
|
||||||
"@stripe/react-stripe-js": "^6.6.0",
|
"@stripe/react-stripe-js": "^6.6.0",
|
||||||
"@stripe/stripe-js": "^9.7.0",
|
"@stripe/stripe-js": "^9.7.0",
|
||||||
"@supabase/ssr": "^0.10.2",
|
|
||||||
"@supabase/supabase-js": "^2.105.3",
|
"@supabase/supabase-js": "^2.105.3",
|
||||||
"@upstash/ratelimit": "^2.0.8",
|
|
||||||
"@upstash/redis": "^1.38.0",
|
|
||||||
"drizzle-orm": "^0.36.4",
|
"drizzle-orm": "^0.36.4",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
@@ -45,24 +41,19 @@
|
|||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
"next-auth": "^5.0.0-beta.31",
|
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"openai": "^6.37.0",
|
"openai": "^6.37.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"posthog-js": "^1.378.1",
|
|
||||||
"posthog-node": "^5.35.11",
|
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"square": "^44.0.1",
|
|
||||||
"stripe": "^22.1.1",
|
"stripe": "^22.1.1",
|
||||||
"uuid": "^11.1.1",
|
"uuid": "^11.1.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lhci/cli": "^0.15.1",
|
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
@@ -81,11 +72,11 @@
|
|||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
|
"react-doctor": "^0.5.8",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tsx": "^4.22.4",
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vitest": "^4.1.9"
|
||||||
"vitest": "^2.1.9"
|
|
||||||
},
|
},
|
||||||
"overrides": "{}"
|
"overrides": "{}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "Water Log — Tuxedo",
|
||||||
|
"short_name": "Water Log",
|
||||||
|
"description": "Field access for Tuxedo water + time tracking",
|
||||||
|
"start_url": "/water",
|
||||||
|
"scope": "/water",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f2f2f7",
|
||||||
|
"theme_color": "#14532d",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
],
|
||||||
|
"categories": ["productivity", "utilities"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// public/sw-field.js — Tuxedo field-worker service worker.
|
||||||
|
//
|
||||||
|
// Cycle 6 sub-PWA, scoped to /water. Tighter cache strategy than
|
||||||
|
// the main shell SW because:
|
||||||
|
// - The field app is a thin surface (PIN, headgate list, log form,
|
||||||
|
// time tab). Few async chunks; mostly static + the latest data
|
||||||
|
// snapshot.
|
||||||
|
// - "Offline" here means "still see the cached PIN screen + headgate
|
||||||
|
// list" — water entry without connectivity is a degraded but
|
||||||
|
// non-blocking experience (the form queue handles drafts).
|
||||||
|
//
|
||||||
|
// Cache names are deliberately prefixed with `field-` so they never
|
||||||
|
// collide with the main `rc-shell-v*` / `rc-data-v*` caches.
|
||||||
|
|
||||||
|
// Bumped to v2 after the Apple HIG polish pass (liquid-glass chrome,
|
||||||
|
// iOS SegmentedControl, ThresholdMeter). The activate handler keeps
|
||||||
|
// only this name + DATA_CACHE — anything else starting with `field-`
|
||||||
|
// (including the old v1) is evicted on the next SW activation, which
|
||||||
|
// happens automatically because the bytes of this file changed.
|
||||||
|
const SHELL_CACHE = "field-shell-v2";
|
||||||
|
const DATA_CACHE = "field-data-v1";
|
||||||
|
const OFFLINE_URL = "/offline.html";
|
||||||
|
|
||||||
|
const SHELL_ASSETS = [
|
||||||
|
"/water",
|
||||||
|
"/manifest-field.json",
|
||||||
|
"/favicon.svg",
|
||||||
|
"/og-default.svg",
|
||||||
|
"/offline.html",
|
||||||
|
"/icons/icon-192x192.png",
|
||||||
|
"/icons/icon-512x512.png",
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
// Only manage field-* caches — never touch the main app's caches.
|
||||||
|
event.waitUntil(
|
||||||
|
Promise.all([
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
const toDelete = [];
|
||||||
|
for (const name of cacheNames) {
|
||||||
|
if (
|
||||||
|
name !== SHELL_CACHE &&
|
||||||
|
name !== DATA_CACHE &&
|
||||||
|
name.startsWith("field-")
|
||||||
|
) {
|
||||||
|
toDelete.push(caches.delete(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.all(toDelete);
|
||||||
|
}),
|
||||||
|
self.clients.claim(),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== "GET") return;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// Out-of-scope requests: skip (let the main app SW handle them
|
||||||
|
// if it's installed for that scope; fall through to network).
|
||||||
|
if (!url.pathname.startsWith("/water")) return;
|
||||||
|
|
||||||
|
// Navigations → network-first, fall back to cache, fall back to offline page.
|
||||||
|
if (req.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(req)
|
||||||
|
.then((res) => {
|
||||||
|
const clone = res.clone();
|
||||||
|
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
caches.match(req).then(
|
||||||
|
(cached) => cached ?? caches.match(OFFLINE_URL),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets → cache-first
|
||||||
|
if (
|
||||||
|
url.pathname.startsWith("/_next/static/") ||
|
||||||
|
url.pathname.startsWith("/icons/") ||
|
||||||
|
url.pathname.endsWith(".svg") ||
|
||||||
|
url.pathname.endsWith(".woff2")
|
||||||
|
) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(req).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(req).then((res) => {
|
||||||
|
const clone = res.clone();
|
||||||
|
if (res.status === 200) {
|
||||||
|
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/water* GETs → stale-while-revalidate (so the headgate list
|
||||||
|
// and worker availability can hydrate from cache while we re-fetch).
|
||||||
|
if (url.pathname.startsWith("/api/water")) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(DATA_CACHE).then((cache) =>
|
||||||
|
cache.match(req).then((cached) => {
|
||||||
|
const network = fetch(req)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status === 200) cache.put(req, res.clone());
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => cached);
|
||||||
|
return cached ?? network;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: try network, fall back to cache.
|
||||||
|
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
||||||
|
});
|
||||||
+9
-7
@@ -26,13 +26,15 @@ self.addEventListener("install", (event) => {
|
|||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
Promise.all([
|
Promise.all([
|
||||||
caches.keys().then((cacheNames) =>
|
caches.keys().then((cacheNames) => {
|
||||||
Promise.all(
|
const toDelete = [];
|
||||||
cacheNames
|
for (const name of cacheNames) {
|
||||||
.filter((name) => name !== SHELL_CACHE && name !== DATA_CACHE)
|
if (name !== SHELL_CACHE && name !== DATA_CACHE) {
|
||||||
.map((name) => caches.delete(name))
|
toDelete.push(caches.delete(name));
|
||||||
)
|
}
|
||||||
),
|
}
|
||||||
|
return Promise.all(toDelete);
|
||||||
|
}),
|
||||||
self.clients.claim(),
|
self.clients.claim(),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from "react-doctor/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React Doctor configuration for the route-commerce-platform monorepo.
|
||||||
|
*
|
||||||
|
* This file intentionally uses `react-doctor/api` so that the dependency is
|
||||||
|
* imported into the source tree (not just invoked via the CLI). Without this
|
||||||
|
* import, `deslop/unused-dev-dependency` correctly flags `react-doctor` in
|
||||||
|
* `package.json` as unused.
|
||||||
|
*
|
||||||
|
* Rules deliberately left at their defaults — nothing is ignored, excluded,
|
||||||
|
* or relaxed. The goal is a clean 100/100 score by fixing root causes, not
|
||||||
|
* by suppressing findings.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
projects: ["."],
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Cycle 12 deep-flow smoke — walks Hub → Manual Entry → submit,
|
||||||
|
* Hub → History. Captures screenshots along the way and asserts
|
||||||
|
* each screen renders the expected layout.
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:4000";
|
||||||
|
const OUT = "tests/_artifacts/cycle12";
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
// Use worker_id 5a83c1a3-... (Worker 1) but spoof the brand so we
|
||||||
|
// hit the Tuxedo brandId the running server expects.
|
||||||
|
const COOKIE_VALUE = [
|
||||||
|
"5a83c1a3-8eb5-44c4-bb66-323e72d38c91",
|
||||||
|
"smoke-" + Math.random().toString(36).slice(2, 10),
|
||||||
|
new Date(Date.now() + 12 * 3600 * 1000).toISOString(),
|
||||||
|
"Worker 1",
|
||||||
|
"time_admin",
|
||||||
|
"en",
|
||||||
|
// Brand 64294306 is the one the server logs show loading from —
|
||||||
|
// matches `TUXEDO_BRAND_ID` constant.
|
||||||
|
"11111111-1111-1111-1111-111111111111",
|
||||||
|
].join("|");
|
||||||
|
|
||||||
|
async function snap(page, name) {
|
||||||
|
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectText(page, regex, label) {
|
||||||
|
const text = await page.locator("body").innerText();
|
||||||
|
if (!regex.test(text)) {
|
||||||
|
console.error(`✗ ${label}: didn't match ${regex}`);
|
||||||
|
console.error("body snapshot:\n" + text.slice(0, 1500));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`✓ ${label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const ctx = await browser.newContext({
|
||||||
|
viewport: { width: 412, height: 900 },
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
});
|
||||||
|
await ctx.addCookies([
|
||||||
|
{
|
||||||
|
name: "time_tracking_session",
|
||||||
|
value: COOKIE_VALUE,
|
||||||
|
url: BASE,
|
||||||
|
httpOnly: false,
|
||||||
|
secure: false,
|
||||||
|
sameSite: "Lax",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
page.on("pageerror", (e) => console.error("PAGE ERROR:", e.message));
|
||||||
|
page.on("console", (m) => {
|
||||||
|
if (m.type() === "error") console.error("CONSOLE ERR:", m.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 1) Hub
|
||||||
|
await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await snap(page, "01_hub");
|
||||||
|
await expectText(page, /Recent Activity/i, "Hub renders 'Recent activity' tile");
|
||||||
|
await expectText(page, /Manual entry/i, "Hub renders 'Manual entry' tile");
|
||||||
|
|
||||||
|
// ── 2) Manual entry
|
||||||
|
await page.locator('button:has-text("Manual entry")').first().click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await snap(page, "02_manual_entry");
|
||||||
|
await expectText(page, /Manual entry/i, "Manual entry screen title");
|
||||||
|
await expectText(page, /WHEN/i, "Manual entry 'WHEN' section");
|
||||||
|
await expectText(page, /WHY/i, "Manual entry 'WHY' section");
|
||||||
|
|
||||||
|
// Try to submit empty — should show reason error
|
||||||
|
// Reason field is the second text input on the page (first is Task)
|
||||||
|
const reasonInput = page.locator('input[placeholder*="Required"]').first();
|
||||||
|
await reasonInput.fill("Forgot to clock in this morning");
|
||||||
|
await snap(page, "03_manual_filled");
|
||||||
|
await expectText(page, /Submit entry/i, "Submit button visible");
|
||||||
|
|
||||||
|
// ── 3) Recent activity
|
||||||
|
await page.locator('button[aria-label*="Atr" i], button[aria-label*="Back"]').first().click().catch(() => {});
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
// We may not always find the back button — fall back to going to hub via reload
|
||||||
|
await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.locator('button:has-text("Recent activity")').first().click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await snap(page, "04_history");
|
||||||
|
await expectText(page, /Recent Activity|Actividad reciente/i, "History title visible");
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log("\n✓ Cycle 12 deep smoke passed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Cycle 12 — submit a manual entry and verify the success screen
|
||||||
|
* renders. Uses yesterday's date so the server's 30-day window
|
||||||
|
* accepts it.
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:4000";
|
||||||
|
const OUT = "tests/_artifacts/cycle12";
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
const COOKIE_VALUE = [
|
||||||
|
"5a83c1a3-8eb5-44c4-bb66-323e72d38c91",
|
||||||
|
"smoke-" + Math.random().toString(36).slice(2, 10),
|
||||||
|
new Date(Date.now() + 12 * 3600 * 1000).toISOString(),
|
||||||
|
"Worker 1",
|
||||||
|
"time_admin",
|
||||||
|
"en",
|
||||||
|
"11111111-1111-1111-1111-111111111111",
|
||||||
|
].join("|");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const ctx = await browser.newContext({
|
||||||
|
viewport: { width: 412, height: 900 },
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
});
|
||||||
|
await ctx.addCookies([
|
||||||
|
{
|
||||||
|
name: "time_tracking_session",
|
||||||
|
value: COOKIE_VALUE,
|
||||||
|
url: BASE,
|
||||||
|
httpOnly: false,
|
||||||
|
secure: false,
|
||||||
|
sameSite: "Lax",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
page.on("pageerror", (e) => console.error("PAGE ERROR:", e.message));
|
||||||
|
page.on("console", (m) => {
|
||||||
|
if (m.type() === "error") console.error("CONSOLE ERR:", m.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Hub → Manual entry
|
||||||
|
await page.locator('button:has-text("Manual entry")').first().click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
|
||||||
|
// Fill form. Date defaults to today; use yesterday's date to make
|
||||||
|
// sure we're definitely within the 30-day window and not in the
|
||||||
|
// future. Use a real-looking shift: 07:00 → 15:30 (8h 30m, minus
|
||||||
|
// 30m lunch = 8h).
|
||||||
|
const yesterday = new Date(Date.now() - 86_400_000);
|
||||||
|
const yyyy = yesterday.getFullYear();
|
||||||
|
const mm = String(yesterday.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(yesterday.getDate()).padStart(2, "0");
|
||||||
|
const dateStr = `${yyyy}-${mm}-${dd}`;
|
||||||
|
|
||||||
|
await page.locator('input[type="date"]').first().fill(dateStr);
|
||||||
|
await page.locator('input[placeholder*="Harvesting"]').first().fill("Harvesting");
|
||||||
|
await page
|
||||||
|
.locator('input[placeholder*="Required"]')
|
||||||
|
.first()
|
||||||
|
.fill("Forgot to clock in this morning");
|
||||||
|
await page.screenshot({ path: `${OUT}/05_manual_before_submit.png` });
|
||||||
|
|
||||||
|
await page.locator('button:has-text("Submit entry")').click();
|
||||||
|
// Wait for either success or error
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await page.screenshot({ path: `${OUT}/06_after_submit.png` });
|
||||||
|
const text = await page.locator("body").innerText();
|
||||||
|
if (/Entry submitted|Entrada enviada/i.test(text)) {
|
||||||
|
console.log("✓ Success screen rendered");
|
||||||
|
} else if (/16 hours|30 days|3 characters|cannot be in the future|Must be after|Reason/i.test(text)) {
|
||||||
|
console.error("✗ Validation error visible:");
|
||||||
|
console.error(text.slice(0, 1500));
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.error("? Unknown state after submit:");
|
||||||
|
console.error(text.slice(0, 1500));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take a clean success shot by scrolling to top
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.screenshot({ path: `${OUT}/07_success.png` });
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* diag-water-pin.mjs — diagnostic script for the "PIN only works
|
||||||
|
* once" bug in the water log module.
|
||||||
|
*
|
||||||
|
* What it does:
|
||||||
|
* 1. Snapshots every row in `water_irrigators` and `water_sessions`.
|
||||||
|
* 2. For each irrigator, verifies the stored `pin_hash` format is
|
||||||
|
* a parseable `scrypt$N$r$p$salt$hash` string.
|
||||||
|
* 3. Cross-checks: any session whose `expires_at < now()` would
|
||||||
|
* fail `requireFieldSession` but should NOT affect `verifyWaterPin`
|
||||||
|
* (verifyWaterPin re-issues a session on success).
|
||||||
|
* 4. Optionally tests a provided PIN against a provided pin_hash to
|
||||||
|
* see if the cryptographic verify matches.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* DATABASE_URL="postgresql://..." node scripts/diag-water-pin.mjs
|
||||||
|
* DATABASE_URL="..." \
|
||||||
|
* node scripts/diag-water-pin.mjs --pin=4739 --id=<irrigator-uuid>
|
||||||
|
*
|
||||||
|
* Run this BEFORE and AFTER a failing second-login attempt to confirm
|
||||||
|
* whether `pin_hash` is being modified mid-session.
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { scryptSync, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
const args = Object.fromEntries(
|
||||||
|
process.argv.slice(2).map((a) => {
|
||||||
|
const m = a.match(/^--([^=]+)(?:=(.*))?$/);
|
||||||
|
return m ? [m[1], m[2] ?? true] : [a, true];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pin = typeof args.pin === "string" ? args.pin : null;
|
||||||
|
const targetId = typeof args.id === "string" ? args.id : null;
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
console.error("DATABASE_URL is not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
ssl: process.env.DATABASE_URL.includes("sslmode")
|
||||||
|
? { rejectUnauthorized: false }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
function verifyPin(rawPin, stored) {
|
||||||
|
if (!stored || !stored.startsWith("scrypt$")) return false;
|
||||||
|
const parts = stored.split("$");
|
||||||
|
if (parts.length !== 6) return false;
|
||||||
|
const [, nStr, rStr, pStr, saltB64, hashB64] = parts;
|
||||||
|
const n = parseInt(nStr, 10),
|
||||||
|
r = parseInt(rStr, 10),
|
||||||
|
p = parseInt(pStr, 10);
|
||||||
|
if (![n, r, p].every(Number.isFinite)) return false;
|
||||||
|
let salt, expected;
|
||||||
|
try {
|
||||||
|
salt = Buffer.from(saltB64, "base64");
|
||||||
|
expected = Buffer.from(hashB64, "base64");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (expected.length !== 32) return false;
|
||||||
|
let candidate;
|
||||||
|
try {
|
||||||
|
candidate = scryptSync(rawPin.normalize("NFKC"), salt, 32, {
|
||||||
|
N: n,
|
||||||
|
r,
|
||||||
|
p,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
candidate.length === expected.length &&
|
||||||
|
timingSafeEqual(candidate, expected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s, n = 60) {
|
||||||
|
return s && s.length > n ? s.slice(0, n) + "..." : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
console.log("=== SNAPSHOT @ " + new Date().toISOString() + " ===\n");
|
||||||
|
|
||||||
|
// Bypass RLS via withPlatformAdmin equivalent (raw app vars).
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query(
|
||||||
|
"SELECT set_config('app.current_brand_id', '', true)",
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
"SELECT set_config('app.platform_admin', 'true', true)",
|
||||||
|
);
|
||||||
|
|
||||||
|
const irrigators = await client.query(
|
||||||
|
"SELECT id, brand_id, name, role, active, " +
|
||||||
|
"substr(pin_hash, 1, 40) || '...' AS pin_hash_preview, " +
|
||||||
|
"length(pin_hash) AS pin_hash_len, last_used_at, created_at " +
|
||||||
|
"FROM water_irrigators ORDER BY name",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("water_irrigators (" + irrigators.rowCount + " rows):");
|
||||||
|
for (const r of irrigators.rows) {
|
||||||
|
console.log(` • [${r.id}] ${r.name}`);
|
||||||
|
console.log(` brand=${r.brand_id}`);
|
||||||
|
console.log(` role=${r.role} active=${r.active}`);
|
||||||
|
console.log(` pin_hash_preview="${r.pin_hash_preview}" (len=${r.pin_hash_len})`);
|
||||||
|
console.log(` last_used_at=${r.last_used_at}`);
|
||||||
|
|
||||||
|
// Format check
|
||||||
|
const fullRow = await client.query(
|
||||||
|
"SELECT pin_hash FROM water_irrigators WHERE id = $1",
|
||||||
|
[r.id],
|
||||||
|
);
|
||||||
|
const ph = fullRow.rows[0]?.pin_hash;
|
||||||
|
if (!ph || !ph.startsWith("scrypt$") || ph.split("$").length !== 6) {
|
||||||
|
console.log(` ⚠️ pin_hash has UNEXPECTED format`);
|
||||||
|
} else {
|
||||||
|
console.log(` ✓ pin_hash is well-formed scrypt string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Targeted PIN verify
|
||||||
|
if (pin && targetId === r.id) {
|
||||||
|
const ok = verifyPin(pin, ph);
|
||||||
|
console.log(` ${ok ? "✓" : "✗"} pin '${pin}' matches pin_hash for ${r.name} → ${ok}`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = await client.query(
|
||||||
|
"SELECT id, irrigator_id, expires_at, created_at FROM water_sessions ORDER BY created_at DESC LIMIT 50",
|
||||||
|
);
|
||||||
|
console.log("\nwater_sessions (" + sessions.rowCount + " recent rows):");
|
||||||
|
for (const s of sessions.rows) {
|
||||||
|
const expired = new Date(s.expires_at).getTime() < Date.now();
|
||||||
|
const irrigator = irrigators.rows.find((i) => i.id === s.irrigator_id);
|
||||||
|
console.log(
|
||||||
|
` • [${s.id}] irrigator=${s.irrigator_id} (${irrigator?.name ?? "?"}) expires_at=${s.expires_at.toISOString()} ${expired ? "(EXPIRED)" : "(active)"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("error:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Replace <a href="/internal"> with <Link href="/internal"> in JSX.
|
||||||
|
* Skips <a> tags that already opt out of navigation (target, download,
|
||||||
|
* rel, mailto, tel, external, hash-only) and any non-string-literal
|
||||||
|
* href expressions.
|
||||||
|
*
|
||||||
|
* Idempotent: re-running on an already-converted file is a no-op.
|
||||||
|
*
|
||||||
|
* Mirrors the react-doctor rule
|
||||||
|
* `react-doctor/nextjs-no-a-element`.
|
||||||
|
*/
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const ts = require("typescript");
|
||||||
|
|
||||||
|
function findInternalAnchorOpeningElements(src) {
|
||||||
|
const sf = ts.createSourceFile("x.tsx", src, ts.ScriptTarget.Latest, true);
|
||||||
|
/** @type {Array<{pos:number,end:number,href:string}>} */
|
||||||
|
const matches = [];
|
||||||
|
function visit(node) {
|
||||||
|
if (
|
||||||
|
ts.isJsxOpeningElement(node) &&
|
||||||
|
ts.isIdentifier(node.tagName) &&
|
||||||
|
node.tagName.text === "a"
|
||||||
|
) {
|
||||||
|
let hrefValue = null;
|
||||||
|
let isInternalLiteral = false;
|
||||||
|
let isOptOut = false;
|
||||||
|
for (const attr of node.attributes.properties) {
|
||||||
|
if (!ts.isJsxAttribute(attr) || !attr.name) continue;
|
||||||
|
const name = ts.isIdentifier(attr.name) ? attr.name.text : attr.name.text;
|
||||||
|
if (name === "href" && attr.initializer && ts.isStringLiteral(attr.initializer)) {
|
||||||
|
hrefValue = attr.initializer.text;
|
||||||
|
if (hrefValue.startsWith("/")) {
|
||||||
|
isInternalLiteral = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (name === "target" || name === "download" || name === "rel" || name === "onClick") {
|
||||||
|
isOptOut = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isInternalLiteral && !isOptOut) {
|
||||||
|
matches.push({
|
||||||
|
pos: node.getStart(sf),
|
||||||
|
end: node.getEnd(),
|
||||||
|
href: hrefValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
visit(sf);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLinkImport(src) {
|
||||||
|
if (/from\s+["']next\/link["']/.test(src)) return src;
|
||||||
|
// Insert after the last existing import line.
|
||||||
|
const sf = ts.createSourceFile("x.tsx", src, ts.ScriptTarget.Latest, true);
|
||||||
|
let lastImportEnd = 0;
|
||||||
|
for (const stmt of sf.statements) {
|
||||||
|
if (ts.isImportDeclaration(stmt)) {
|
||||||
|
lastImportEnd = Math.max(lastImportEnd, stmt.getEnd());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastImportEnd === 0) {
|
||||||
|
return `import Link from "next/link";\n${src}`;
|
||||||
|
}
|
||||||
|
return `${src.slice(0, lastImportEnd)}\nimport Link from "next/link";${src.slice(lastImportEnd)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processFile(filePath) {
|
||||||
|
const abs = path.resolve(filePath);
|
||||||
|
if (!fs.existsSync(abs)) {
|
||||||
|
console.error(`skip (not found): ${abs}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const original = fs.readFileSync(abs, "utf8");
|
||||||
|
const matches = findInternalAnchorOpeningElements(original);
|
||||||
|
if (matches.length === 0) return 0;
|
||||||
|
|
||||||
|
let out = original;
|
||||||
|
// Apply edits in reverse so positions don't shift.
|
||||||
|
matches.sort((a, b) => b.pos - a.pos);
|
||||||
|
for (const m of matches) {
|
||||||
|
out = out.slice(0, m.pos) + `<Link href="${m.href}"` + out.slice(m.end);
|
||||||
|
}
|
||||||
|
// Replace any closing </a> with </Link> in the same file. Safer
|
||||||
|
// to do this as a single global swap after the opening-tag edits
|
||||||
|
// since the file is the scope.
|
||||||
|
out = out.replace(/<\/a>/g, "</Link>");
|
||||||
|
|
||||||
|
out = ensureLinkImport(out);
|
||||||
|
if (out !== original) {
|
||||||
|
fs.writeFileSync(abs, out, "utf8");
|
||||||
|
console.log(`patched (${matches.length} anchors): ${abs}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = process.argv.slice(2);
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.error("usage: node scripts/fix-next-anchor.js <file>...");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
let n = 0;
|
||||||
|
for (const f of files) {
|
||||||
|
n += processFile(f);
|
||||||
|
}
|
||||||
|
console.log(`done — ${n} files patched`);
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Convert Zod 3 chained format calls to the Zod 4 top-level API.
|
||||||
|
* Idempotent: re-running on an already-converted file is a no-op.
|
||||||
|
*
|
||||||
|
* Before: z.string().email()
|
||||||
|
* After: z.email()
|
||||||
|
*
|
||||||
|
* Mirrors https://zod.dev/v4/changelog and the react-doctor rule
|
||||||
|
* `react-doctor/zod-v4-prefer-top-level-string-formats`.
|
||||||
|
*/
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const SIMPLE_METHODS = [
|
||||||
|
"email",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
|
"cuid",
|
||||||
|
"cuid2",
|
||||||
|
"nanoid",
|
||||||
|
"ulid",
|
||||||
|
"emoji",
|
||||||
|
"jwt",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SIMPLE_REGEX = new RegExp(
|
||||||
|
String.raw`\b(z|zod|schema)(\.string\(\))\.(${SIMPLE_METHODS.join("|")})\(`,
|
||||||
|
"g",
|
||||||
|
);
|
||||||
|
|
||||||
|
const ISO_REGEX = new RegExp(
|
||||||
|
String.raw`\b(z|zod|schema)(\.string\(\))\.(${"datetime|date|time|ip|ipv4|ipv6|cidr|cidrv4|cidrv6"})\(`,
|
||||||
|
"g",
|
||||||
|
);
|
||||||
|
|
||||||
|
function patch(src) {
|
||||||
|
let out = src;
|
||||||
|
|
||||||
|
// email/url/uuid/etc — drop the .string() wrapper
|
||||||
|
out = out.replace(SIMPLE_REGEX, (_, z, _str, method) => `${z}.${method}(`);
|
||||||
|
|
||||||
|
// iso.* — wrap the leaf call in .iso.<format>(...)
|
||||||
|
out = out.replace(ISO_REGEX, (_, z, _str, method) => `${z}.iso.${method}(`);
|
||||||
|
|
||||||
|
return out === src ? null : out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processFile(filePath) {
|
||||||
|
const abs = path.resolve(filePath);
|
||||||
|
if (!fs.existsSync(abs)) {
|
||||||
|
console.error(`skip (not found): ${abs}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const original = fs.readFileSync(abs, "utf8");
|
||||||
|
if (!/from\s+["']zod["']/.test(original)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const patched = patch(original);
|
||||||
|
if (patched) {
|
||||||
|
fs.writeFileSync(abs, patched, "utf8");
|
||||||
|
console.log(`patched: ${abs}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = process.argv.slice(2);
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.error("usage: node scripts/fix-zod-format.js <file>...");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
let n = 0;
|
||||||
|
for (const f of files) {
|
||||||
|
n += processFile(f);
|
||||||
|
}
|
||||||
|
console.log(`done — ${n} files patched`);
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Regression test: brand logo (and all /brand-logos/... images) must load
|
||||||
|
// through Next.js Image optimizer on the tuxedo storefront.
|
||||||
|
//
|
||||||
|
// Pre-fix state:
|
||||||
|
// GET https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-...%2Flogo.png
|
||||||
|
// → HTTP 400, body: "url parameter is not allowed"
|
||||||
|
//
|
||||||
|
// Post-fix expectation:
|
||||||
|
// → HTTP 200, content-type: image/* and non-empty body
|
||||||
|
//
|
||||||
|
// Root cause identified: Next.js standalone build is missing the
|
||||||
|
// `.next/standalone/public/` folder. The deploy workflow copies
|
||||||
|
// `.next/static` but not `public/`. Without `public/` in standalone, the
|
||||||
|
// image optimizer can't resolve local-relative URLs.
|
||||||
|
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
|
|
||||||
|
const URL = 'https://water.tuxedocorn.com/tuxedo?_=' + Date.now();
|
||||||
|
const LOGO_PROXY =
|
||||||
|
'https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-5f42-463d-a5e8-2ad6c81a96de%2Flogo.png&w=256&q=75';
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
// 1. Hit the image proxy directly
|
||||||
|
const r = await fetch(LOGO_PROXY, {
|
||||||
|
headers: { 'User-Agent': 'regression-brand-logos/1.0' },
|
||||||
|
});
|
||||||
|
if (r.status !== 200) {
|
||||||
|
failures.push(`Logo proxy returned HTTP ${r.status} (expected 200)`);
|
||||||
|
} else {
|
||||||
|
const ct = r.headers.get('content-type') ?? '';
|
||||||
|
if (!ct.startsWith('image/')) failures.push(`Logo proxy content-type was ${ct} (expected image/*)`);
|
||||||
|
const buf = new Uint8Array(await r.arrayBuffer());
|
||||||
|
if (buf.length < 1000) failures.push(`Logo proxy body too small: ${buf.length} bytes`);
|
||||||
|
console.log(` Logo proxy: HTTP ${r.status} ${ct} ${buf.length}B`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verify via headless browser — visit page, check that the logo <img> actually rendered.
|
||||||
|
const { chromium } = await import('playwright');
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const failed = [];
|
||||||
|
page.on('response', (resp) => {
|
||||||
|
const u = resp.url();
|
||||||
|
if (u.includes('brand-logos') && resp.status() >= 400) failed.push(`HTTP ${resp.status()} ${u.slice(0, 120)}`);
|
||||||
|
});
|
||||||
|
await page.goto(URL, { waitUntil: 'networkidle', timeout: 30000 });
|
||||||
|
// Scroll to bottom so footer logo loads.
|
||||||
|
await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }));
|
||||||
|
await sleep(4000);
|
||||||
|
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
const logoInfo = await page.evaluate(() => {
|
||||||
|
const imgs = Array.from(document.querySelectorAll('img'));
|
||||||
|
const logos = imgs.filter((i) => (i.currentSrc || i.src || '').includes('brand-logos'));
|
||||||
|
return logos.map((i) => ({
|
||||||
|
alt: i.alt.slice(0, 40),
|
||||||
|
currentSrc: (i.currentSrc || i.src || '').slice(0, 130),
|
||||||
|
naturalWidth: i.naturalWidth,
|
||||||
|
naturalHeight: i.naturalHeight,
|
||||||
|
complete: i.complete,
|
||||||
|
visible: i.getBoundingClientRect().height > 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
console.log('\n LOGO IMAGES ON PAGE:');
|
||||||
|
for (const l of logoInfo) console.log(' ', JSON.stringify(l));
|
||||||
|
|
||||||
|
const anyLogoBlank = logoInfo.some((l) => l.naturalWidth === 0 || !l.complete);
|
||||||
|
if (anyLogoBlank) failures.push(`Logo image(s) failed to render: ${JSON.stringify(logoInfo)}`);
|
||||||
|
if (failed.length > 0) failures.push(`Network failed for brand-logos URLs: ${failed.join(' | ')}`);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error('\n❌ REGRESSION FAILED:');
|
||||||
|
for (const f of failures) console.error(' -', f);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log('\n✅ Brand logos serve correctly through Next.js Image optimizer.');
|
||||||
|
}
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Production startup wrapper for `next start` when `output: "standalone"`
|
||||||
|
* is set in next.config.ts. Loads .env from a known absolute location,
|
||||||
|
* then exec's the standalone server with the loaded env.
|
||||||
|
*
|
||||||
|
* Why this exists:
|
||||||
|
* 1. `next start` against a standalone build is unsupported by Next.js —
|
||||||
|
* it prints "next start does not work with output: standalone
|
||||||
|
* configuration. Use `node .next/standalone/server.js` instead" — and
|
||||||
|
* silently disables the image optimizer (every `/_next/image?url=...`
|
||||||
|
* returned "url parameter is not allowed"), which is why all the
|
||||||
|
* WP-imported brand imagery never rendered. See commit 129c9d2.
|
||||||
|
*
|
||||||
|
* 2. bash's `set -a; . ./.env` truncates DATABASE_URL at the `&` in
|
||||||
|
* `&channel_binding=require`, so the server boots with empty DB env
|
||||||
|
* and the public storefront falls back to dark gradient backgrounds.
|
||||||
|
* A small Node loader sidesteps the bash word-splitting.
|
||||||
|
*
|
||||||
|
* 3. The standalone server reads `.next/static/` relative to itself, so
|
||||||
|
* the deploy workflow must `cp -r .next/static .next/standalone/.next/static`
|
||||||
|
* after every sync.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* PORT=3100 HOSTNAME=0.0.0.0 \
|
||||||
|
* NODE_BIN=/home/tyler/.cache/act/tool_cache/node/22.22.3/x64/bin/node \
|
||||||
|
* node /home/tyler/route-commerce/scripts/start-standalone.cjs
|
||||||
|
*
|
||||||
|
* pm2 typically overrides interpreter with its own node and sets `cwd`
|
||||||
|
* arbitrarily, so we resolve the project root from the script's own path
|
||||||
|
* instead of `process.cwd()`.
|
||||||
|
*/
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const APP_DIR = path.resolve(__dirname, "..");
|
||||||
|
const ENV_PATH = process.env.ENV_FILE || path.join(APP_DIR, ".env");
|
||||||
|
const STANDALONE = path.join(APP_DIR, ".next", "standalone", "server.js");
|
||||||
|
|
||||||
|
if (!fs.existsSync(STANDALONE)) {
|
||||||
|
console.error(`[start-standalone] Missing standalone server at ${STANDALONE}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(ENV_PATH)) {
|
||||||
|
for (const line of fs.readFileSync(ENV_PATH, "utf8").split("\n")) {
|
||||||
|
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||||
|
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
console.log(`[start-standalone] loaded env from ${ENV_PATH}`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[start-standalone] no .env at ${ENV_PATH}; relying on existing env`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand off to the standalone server. Re-exec keeps the process title at
|
||||||
|
// `next-server` (next-start.sh sets it) and matches `node .next/standalone/server.js`.
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
const child = spawn(process.execPath, [STANDALONE], { stdio: "inherit", env: process.env });
|
||||||
|
child.on("exit", (code) => process.exit(code ?? 0));
|
||||||
|
for (const sig of ["SIGINT", "SIGTERM"]) {
|
||||||
|
process.on(sig, () => child.kill(sig));
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser, type AdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser, type AdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export async function getCurrentAdminUser(): Promise<AdminUser | null> {
|
export async function getCurrentAdminUser(): Promise<AdminUser | null> {
|
||||||
return getAdminUser();
|
|
||||||
|
await getSession(); return getAdminUser();
|
||||||
}
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { pool } from "@/lib/db";
|
|
||||||
|
|
||||||
type AdminActionPayload = {
|
|
||||||
action_type: "create" | "update" | "delete";
|
|
||||||
admin_id?: string;
|
|
||||||
admin_email?: string;
|
|
||||||
affected_user_id?: string;
|
|
||||||
brand_id?: string;
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UserActivityPayload = {
|
|
||||||
user_id: string;
|
|
||||||
activity_type: "login" | "logout" | "password_change" | "profile_update" | "email_change";
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
ip_address?: string;
|
|
||||||
user_agent?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function logAdminAction(payload: AdminActionPayload): Promise<void> {
|
|
||||||
try {
|
|
||||||
await pool.query("SELECT log_admin_action($1::jsonb)", [
|
|
||||||
JSON.stringify({
|
|
||||||
action_type: payload.action_type,
|
|
||||||
admin_id: payload.admin_id ?? null,
|
|
||||||
admin_email: payload.admin_email ?? null,
|
|
||||||
affected_user_id: payload.affected_user_id ?? null,
|
|
||||||
brand_id: payload.brand_id ?? null,
|
|
||||||
details: payload.details ?? {},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} catch {
|
|
||||||
// logging failed silently
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logUserActivity(payload: UserActivityPayload): Promise<void> {
|
|
||||||
try {
|
|
||||||
await pool.query("SELECT log_user_activity($1::jsonb)", [
|
|
||||||
JSON.stringify({
|
|
||||||
user_id: payload.user_id,
|
|
||||||
activity_type: payload.activity_type,
|
|
||||||
details: payload.details ?? {},
|
|
||||||
ip_address: payload.ip_address ?? null,
|
|
||||||
user_agent: payload.user_agent ?? null,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} catch {
|
|
||||||
// logging failed silently
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import "server-only";
|
|||||||
import { getSession } from "@/lib/auth";
|
import { getSession } from "@/lib/auth";
|
||||||
import { setUserPassword } from "@/lib/auth";
|
import { setUserPassword } from "@/lib/auth";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { serverLog, serverError } from "@/lib/server-log";
|
||||||
|
|
||||||
const MIN_PASSWORD_LENGTH = 8;
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
@@ -13,11 +14,12 @@ const MIN_PASSWORD_LENGTH = 8;
|
|||||||
* Use this for admin-initiated password resets. For self-service
|
* Use this for admin-initiated password resets. For self-service
|
||||||
* password changes, users should use the forgot password flow.
|
* password changes, users should use the forgot password flow.
|
||||||
*/
|
*/
|
||||||
export async function updatePasswordAction(
|
async function updatePasswordAction(
|
||||||
userId: string,
|
userId: string,
|
||||||
newPassword: string
|
newPassword: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
// Verify the caller is an admin
|
|
||||||
|
await getSession(); // Verify the caller is an admin
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) {
|
if (!adminUser) {
|
||||||
return { success: false, error: "Not authenticated. Please log in again." };
|
return { success: false, error: "Not authenticated. Please log in again." };
|
||||||
@@ -39,14 +41,14 @@ export async function updatePasswordAction(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
console.error("[updatePassword] Failed:", result.error);
|
serverError("[updatePassword] Failed:", result.error);
|
||||||
return { success: false, error: result.error.message ?? "Failed to update password." };
|
return { success: false, error: result.error.message ?? "Failed to update password." };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[updatePassword] Password updated for user:", userId);
|
serverLog("[updatePassword] Password updated for user:", userId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[updatePassword] Unexpected error:", err);
|
serverError("[updatePassword] Unexpected error:", err);
|
||||||
return { success: false, error: "An unexpected error occurred." };
|
return { success: false, error: "An unexpected error occurred." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
import { serverWarn } from "@/lib/server-log";
|
||||||
import {
|
import {
|
||||||
requestPasswordReset as neonAuthRequestPasswordReset,
|
requestPasswordReset as neonAuthRequestPasswordReset,
|
||||||
setUserPassword as neonAuthSetUserPassword,
|
setUserPassword as neonAuthSetUserPassword,
|
||||||
} from "@/lib/auth";
|
} from "@/lib/auth";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type ResetAdminPasswordResult =
|
export type ResetAdminPasswordResult =
|
||||||
| { success: true; tempPassword: string; method: "set" }
|
| { success: true; tempPassword: string; method: "set" }
|
||||||
@@ -34,7 +36,8 @@ export type ResetAdminPasswordResult =
|
|||||||
export async function resetAdminPassword(
|
export async function resetAdminPassword(
|
||||||
email: string,
|
email: string,
|
||||||
): Promise<ResetAdminPasswordResult> {
|
): Promise<ResetAdminPasswordResult> {
|
||||||
// 1. Authz check.
|
|
||||||
|
await getSession(); // 1. Authz check.
|
||||||
const me = await getAdminUser();
|
const me = await getAdminUser();
|
||||||
if (!me) {
|
if (!me) {
|
||||||
return { success: false, error: "Not authenticated." };
|
return { success: false, error: "Not authenticated." };
|
||||||
@@ -82,7 +85,7 @@ export async function resetAdminPassword(
|
|||||||
code === "FORBIDDEN" ||
|
code === "FORBIDDEN" ||
|
||||||
code === "UNAUTHORIZED" ||
|
code === "UNAUTHORIZED" ||
|
||||||
code === "INTERNAL_SERVER_ERROR";
|
code === "INTERNAL_SERVER_ERROR";
|
||||||
console.warn(
|
serverWarn(
|
||||||
"[resetAdminPassword] setUserPassword failed (code=%s), will%s fall back: %s",
|
"[resetAdminPassword] setUserPassword failed (code=%s), will%s fall back: %s",
|
||||||
code,
|
code,
|
||||||
canFallback ? "" : " NOT",
|
canFallback ? "" : " NOT",
|
||||||
@@ -105,7 +108,7 @@ export async function resetAdminPassword(
|
|||||||
[user.id],
|
[user.id],
|
||||||
);
|
);
|
||||||
} catch (flagErr) {
|
} catch (flagErr) {
|
||||||
console.warn(
|
serverWarn(
|
||||||
"[resetAdminPassword] failed to set must_change_password (non-fatal):",
|
"[resetAdminPassword] failed to set must_change_password (non-fatal):",
|
||||||
flagErr,
|
flagErr,
|
||||||
);
|
);
|
||||||
|
|||||||
+19
-10
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
import "server-only";
|
import "server-only";
|
||||||
import { query, withTx } from "@/lib/db";
|
import { query, withTx } from "@/lib/db";
|
||||||
|
import { serverWarn } from "@/lib/server-log";
|
||||||
import {
|
import {
|
||||||
createUser as neonAuthCreateUser,
|
createUser as neonAuthCreateUser,
|
||||||
requestPasswordReset as neonAuthRequestPasswordReset,
|
requestPasswordReset as neonAuthRequestPasswordReset,
|
||||||
} from "@/lib/auth";
|
} from "@/lib/auth";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type AdminUserRow = {
|
export type AdminUserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -130,7 +132,7 @@ async function sendWelcomeEmailSafe(input: {
|
|||||||
brandName = settings.settings.brand_name ?? brandName;
|
brandName = settings.settings.brand_name ?? brandName;
|
||||||
}
|
}
|
||||||
} catch (brandErr) {
|
} catch (brandErr) {
|
||||||
console.warn(
|
serverWarn(
|
||||||
"[createAdminUser] Failed to load brand settings for welcome email:",
|
"[createAdminUser] Failed to load brand settings for welcome email:",
|
||||||
brandErr,
|
brandErr,
|
||||||
);
|
);
|
||||||
@@ -156,7 +158,8 @@ async function sendWelcomeEmailSafe(input: {
|
|||||||
// ─── Public actions ─────────────────────────────────────────────────────────
|
// ─── Public actions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const sql = brandId
|
const sql = brandId
|
||||||
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
||||||
au.role, au.brand_id, b.name AS brand_name,
|
au.role, au.brand_id, b.name AS brand_name,
|
||||||
@@ -237,7 +240,8 @@ export type CreateAdminUserResult = {
|
|||||||
export async function createAdminUser(
|
export async function createAdminUser(
|
||||||
input: CreateAdminUserInput,
|
input: CreateAdminUserInput,
|
||||||
): Promise<CreateAdminUserResult> {
|
): Promise<CreateAdminUserResult> {
|
||||||
// 1. Authorization: only platform admins can mint new admin users.
|
|
||||||
|
await getSession(); // 1. Authorization: only platform admins can mint new admin users.
|
||||||
const caller = await getAdminUser();
|
const caller = await getAdminUser();
|
||||||
if (!caller) {
|
if (!caller) {
|
||||||
return { user: null, error: "Not authenticated. Please sign in again." };
|
return { user: null, error: "Not authenticated. Please sign in again." };
|
||||||
@@ -273,7 +277,7 @@ export async function createAdminUser(
|
|||||||
// admin endpoint requires the caller to have role='admin' in
|
// admin endpoint requires the caller to have role='admin' in
|
||||||
// Neon Auth, which is not always true (provision-admin.ts does
|
// Neon Auth, which is not always true (provision-admin.ts does
|
||||||
// not set it). Falling back to sign-up is a safe, well-tested
|
// not set it). Falling back to sign-up is a safe, well-tested
|
||||||
// path — see scripts/seed-admin.ts.
|
// path.
|
||||||
const code = adminResult.error.code ?? "";
|
const code = adminResult.error.code ?? "";
|
||||||
const isAuthRequired =
|
const isAuthRequired =
|
||||||
code === "FAILED_TO_CREATE_USER" ||
|
code === "FAILED_TO_CREATE_USER" ||
|
||||||
@@ -492,7 +496,7 @@ async function signupFallbackCreate(
|
|||||||
try {
|
try {
|
||||||
await query(`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`, [userId]);
|
await query(`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`, [userId]);
|
||||||
} catch (verifyErr) {
|
} catch (verifyErr) {
|
||||||
console.warn(
|
serverWarn(
|
||||||
"[createAdminUser] Failed to set emailVerified=true (non-fatal):",
|
"[createAdminUser] Failed to set emailVerified=true (non-fatal):",
|
||||||
verifyErr,
|
verifyErr,
|
||||||
);
|
);
|
||||||
@@ -508,7 +512,8 @@ async function signupFallbackCreate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
// Build a partial SET clause. Each `can_manage_*` column is set
|
// Build a partial SET clause. Each `can_manage_*` column is set
|
||||||
// individually — the input's `flags` partial is spread across them.
|
// individually — the input's `flags` partial is spread across them.
|
||||||
const sets: string[] = [];
|
const sets: string[] = [];
|
||||||
@@ -544,7 +549,8 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
|
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
// No Supabase Auth — nothing to delete from the auth service.
|
// No Supabase Auth — nothing to delete from the auth service.
|
||||||
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
|
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
|
||||||
return { success: (rowCount ?? 0) > 0, error: null };
|
return { success: (rowCount ?? 0) > 0, error: null };
|
||||||
@@ -554,7 +560,8 @@ export async function deleteAdminUser(id: string): Promise<{ success: boolean; e
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
|
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const { rowCount } = await query(
|
const { rowCount } = await query(
|
||||||
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
|
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
|
||||||
[userId],
|
[userId],
|
||||||
@@ -581,7 +588,8 @@ export async function setMustChangePassword(userId: string): Promise<{ success:
|
|||||||
export async function sendPasswordResetEmail(
|
export async function sendPasswordResetEmail(
|
||||||
email: string,
|
email: string,
|
||||||
): Promise<{ success: boolean; error: string | null }> {
|
): Promise<{ success: boolean; error: string | null }> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
// Authz: must be signed in as a platform_admin.
|
// Authz: must be signed in as a platform_admin.
|
||||||
const me = await getAdminUser();
|
const me = await getAdminUser();
|
||||||
if (!me) {
|
if (!me) {
|
||||||
@@ -622,7 +630,8 @@ export async function sendPasswordResetEmail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
|
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
// Sort by name so the platform admin's brand picker stays stable.
|
// Sort by name so the platform admin's brand picker stays stable.
|
||||||
const { rows } = await query<{ id: string; name: string }>(
|
const { rows } = await query<{ id: string; name: string }>(
|
||||||
`SELECT id, name FROM brands ORDER BY name`,
|
`SELECT id, name FROM brands ORDER BY name`,
|
||||||
|
|||||||
+100
-24
@@ -7,6 +7,7 @@ import { importProductsBatch } from "@/actions/import-products";
|
|||||||
import { importOrdersBatch } from "@/actions/import-orders";
|
import { importOrdersBatch } from "@/actions/import-orders";
|
||||||
import { createStopsBatch } from "@/actions/stops";
|
import { createStopsBatch } from "@/actions/stops";
|
||||||
import { importContactsBatch } from "@/actions/communications/contacts";
|
import { importContactsBatch } from "@/actions/communications/contacts";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type ImportEntityType = "products" | "orders" | "contacts" | "stops" | "unknown";
|
export type ImportEntityType = "products" | "orders" | "contacts" | "stops" | "unknown";
|
||||||
|
|
||||||
@@ -40,7 +41,8 @@ export async function analyzeImport(
|
|||||||
fileName: string,
|
fileName: string,
|
||||||
brandId: string
|
brandId: string
|
||||||
): Promise<AnalyzeImportResult> {
|
): Promise<AnalyzeImportResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
try {
|
try {
|
||||||
assertBrandAccess(adminUser, brandId);
|
assertBrandAccess(adminUser, brandId);
|
||||||
@@ -98,7 +100,8 @@ export async function executeImport(
|
|||||||
detectedType: ImportEntityType,
|
detectedType: ImportEntityType,
|
||||||
rows: Record<string, unknown>[]
|
rows: Record<string, unknown>[]
|
||||||
): Promise<ExecuteImportResult> {
|
): Promise<ExecuteImportResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
switch (detectedType) {
|
switch (detectedType) {
|
||||||
@@ -255,15 +258,30 @@ function fallbackParse(
|
|||||||
stops: stopKeywords,
|
stops: stopKeywords,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const keywordPatterns: Record<string, RegExp> = {
|
||||||
|
products: new RegExp(productKeywords.map(escapeRegex).join("|")),
|
||||||
|
orders: new RegExp(orderKeywords.map(escapeRegex).join("|")),
|
||||||
|
contacts: new RegExp(contactKeywords.map(escapeRegex).join("|")),
|
||||||
|
stops: new RegExp(stopKeywords.map(escapeRegex).join("|")),
|
||||||
|
};
|
||||||
|
|
||||||
for (const header of h) {
|
for (const header of h) {
|
||||||
for (const [etype, keywords] of Object.entries(keywordMaps)) {
|
for (const [etype, pattern] of Object.entries(keywordPatterns)) {
|
||||||
for (const kw of keywords) {
|
const matches = header.match(pattern);
|
||||||
if (header.includes(kw)) typeScores[etype] = (typeScores[etype] ?? 0) + 1;
|
if (matches) typeScores[etype] = (typeScores[etype] ?? 0) + matches.length;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const detectedType = (Object.entries(typeScores).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown") as ImportEntityType;
|
let bestEtype: string | undefined;
|
||||||
|
let bestScore = -1;
|
||||||
|
for (const [etype, score] of Object.entries(typeScores)) {
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestEtype = etype;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const detectedType = (bestEtype ?? "unknown") as ImportEntityType;
|
||||||
|
|
||||||
// Map headers to semantic fields
|
// Map headers to semantic fields
|
||||||
const columnMappings: ColumnMapping = {};
|
const columnMappings: ColumnMapping = {};
|
||||||
@@ -298,10 +316,16 @@ function fallbackParse(
|
|||||||
notes: ["notes", "note", "special_instructions", "comments", "memo", "instruction"],
|
notes: ["notes", "note", "special_instructions", "comments", "memo", "instruction"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildPattern = (kws: readonly string[]) =>
|
||||||
|
new RegExp(kws.map(escapeRegex).join("|"));
|
||||||
|
const semanticPatterns: Record<string, RegExp> = Object.fromEntries(
|
||||||
|
Object.entries(semanticMap).map(([field, kws]) => [field, buildPattern(kws)]),
|
||||||
|
);
|
||||||
|
|
||||||
for (let i = 0; i < h.length; i++) {
|
for (let i = 0; i < h.length; i++) {
|
||||||
const header = h[i];
|
const header = h[i];
|
||||||
for (const [field, keywords] of Object.entries(semanticMap)) {
|
for (const [field, pattern] of Object.entries(semanticPatterns)) {
|
||||||
if (keywords.some((kw) => header.includes(kw))) {
|
if (pattern.test(header)) {
|
||||||
columnMappings[headers[i]] = field;
|
columnMappings[headers[i]] = field;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,14 +356,26 @@ function fallbackParse(
|
|||||||
// ── Import Executors ─────────────────────────────────────────────────────────
|
// ── Import Executors ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function executeProductsImport(brandId: string, rows: Record<string, unknown>[]) {
|
async function executeProductsImport(brandId: string, rows: Record<string, unknown>[]) {
|
||||||
const products = rows.map((r) => ({
|
const products: Array<{
|
||||||
name: String(r.product_name ?? r.name ?? ""),
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
type: string;
|
||||||
|
active: boolean;
|
||||||
|
image_url: string | undefined;
|
||||||
|
}> = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
const name = String(r.product_name ?? r.name ?? "");
|
||||||
|
if (name === "") continue;
|
||||||
|
products.push({
|
||||||
|
name,
|
||||||
description: String(r.description ?? ""),
|
description: String(r.description ?? ""),
|
||||||
price: parseFloat(String(r.price ?? "0").replace(/[^0-9.]/g, "")) || 0,
|
price: parseFloat(String(r.price ?? "0").replace(/[^0-9.]/g, "")) || 0,
|
||||||
type: normalizeFulfillmentType(String(r.product_type ?? r.type ?? "Pickup")),
|
type: normalizeFulfillmentType(String(r.product_type ?? r.type ?? "Pickup")),
|
||||||
active: String(r.active ?? "true").toLowerCase() !== "false",
|
active: String(r.active ?? "true").toLowerCase() !== "false",
|
||||||
image_url: r.image_url ? String(r.image_url) : undefined,
|
image_url: r.image_url ? String(r.image_url) : undefined,
|
||||||
})).filter((p) => p.name !== "");
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const result = await importProductsBatch(brandId, products);
|
const result = await importProductsBatch(brandId, products);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -377,15 +413,24 @@ async function executeOrdersImport(brandId: string, rows: Record<string, unknown
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const orders = Object.values(orderMap)
|
const orders: Array<{
|
||||||
.filter((o) => o.customer_email && o.stop_id)
|
customer_name: string;
|
||||||
.map((o) => ({
|
customer_email: string;
|
||||||
|
customer_phone: string;
|
||||||
|
stop_id: string;
|
||||||
|
items: { product_id: string; quantity: number; fulfillment: string }[];
|
||||||
|
}> = [];
|
||||||
|
for (const o of Object.values(orderMap)) {
|
||||||
|
if (o.customer_email && o.stop_id) {
|
||||||
|
orders.push({
|
||||||
customer_name: o.customer_name || "",
|
customer_name: o.customer_name || "",
|
||||||
customer_email: o.customer_email,
|
customer_email: o.customer_email,
|
||||||
customer_phone: o.customer_phone || "",
|
customer_phone: o.customer_phone || "",
|
||||||
stop_id: o.stop_id,
|
stop_id: o.stop_id,
|
||||||
items: o.items,
|
items: o.items,
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return importOrdersBatch(brandId, orders).then((r) => {
|
return importOrdersBatch(brandId, orders).then((r) => {
|
||||||
if (r.success) {
|
if (r.success) {
|
||||||
@@ -400,16 +445,31 @@ async function executeOrdersImport(brandId: string, rows: Record<string, unknown
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function executeStopsImport(brandId: string, rows: Record<string, unknown>[]) {
|
async function executeStopsImport(brandId: string, rows: Record<string, unknown>[]) {
|
||||||
const stops = rows.map((r) => ({
|
const stops: Array<{
|
||||||
city: String(r.city ?? ""),
|
city: string;
|
||||||
state: String(r.state ?? ""),
|
state: string;
|
||||||
|
location: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
address: string | undefined;
|
||||||
|
zip: string | undefined;
|
||||||
|
notes: string | undefined;
|
||||||
|
}> = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
const city = String(r.city ?? "");
|
||||||
|
const state = String(r.state ?? "");
|
||||||
|
if (city === "" || state === "") continue;
|
||||||
|
stops.push({
|
||||||
|
city,
|
||||||
|
state,
|
||||||
location: String(r.location ?? r.address ?? ""),
|
location: String(r.location ?? r.address ?? ""),
|
||||||
date: normalizeDate(String(r.date ?? "")),
|
date: normalizeDate(String(r.date ?? "")),
|
||||||
time: String(r.time ?? ""),
|
time: String(r.time ?? ""),
|
||||||
address: r.address ? String(r.address) : undefined,
|
address: r.address ? String(r.address) : undefined,
|
||||||
zip: r.zip ? String(r.zip) : undefined,
|
zip: r.zip ? String(r.zip) : undefined,
|
||||||
notes: r.notes ? String(r.notes) : undefined,
|
notes: r.notes ? String(r.notes) : undefined,
|
||||||
})).filter((s) => s.city !== "" && s.state !== "");
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return createStopsBatch(brandId, stops).then((r) => {
|
return createStopsBatch(brandId, stops).then((r) => {
|
||||||
if (r.success) {
|
if (r.success) {
|
||||||
@@ -420,9 +480,24 @@ async function executeStopsImport(brandId: string, rows: Record<string, unknown>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function executeContactsImport(brandId: string, rows: Record<string, unknown>[]) {
|
async function executeContactsImport(brandId: string, rows: Record<string, unknown>[]) {
|
||||||
const contacts = rows.map((r) => ({
|
const contacts: Array<{
|
||||||
email: r.email ? String(r.email).toLowerCase().trim() : undefined,
|
email: string | undefined;
|
||||||
phone: r.phone ? String(r.phone).trim() : undefined,
|
phone: string | undefined;
|
||||||
|
first_name: string | undefined;
|
||||||
|
last_name: string | undefined;
|
||||||
|
full_name: string | undefined;
|
||||||
|
tags: string[];
|
||||||
|
email_opt_in: boolean;
|
||||||
|
sms_opt_in: boolean;
|
||||||
|
external_id: string | undefined;
|
||||||
|
}> = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
const email = r.email ? String(r.email).toLowerCase().trim() : undefined;
|
||||||
|
const phone = r.phone ? String(r.phone).trim() : undefined;
|
||||||
|
if (!email && !phone) continue;
|
||||||
|
contacts.push({
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
first_name: r.first_name ? String(r.first_name).trim() : undefined,
|
first_name: r.first_name ? String(r.first_name).trim() : undefined,
|
||||||
last_name: r.last_name ? String(r.last_name).trim() : undefined,
|
last_name: r.last_name ? String(r.last_name).trim() : undefined,
|
||||||
full_name: r.full_name ? String(r.full_name).trim() : undefined,
|
full_name: r.full_name ? String(r.full_name).trim() : undefined,
|
||||||
@@ -430,7 +505,8 @@ async function executeContactsImport(brandId: string, rows: Record<string, unkno
|
|||||||
email_opt_in: r.email_opt_in !== undefined ? String(r.email_opt_in).toLowerCase() === "true" : true,
|
email_opt_in: r.email_opt_in !== undefined ? String(r.email_opt_in).toLowerCase() === "true" : true,
|
||||||
sms_opt_in: r.sms_opt_in !== undefined ? String(r.sms_opt_in).toLowerCase() === "true" : false,
|
sms_opt_in: r.sms_opt_in !== undefined ? String(r.sms_opt_in).toLowerCase() === "true" : false,
|
||||||
external_id: r.external_id ? String(r.external_id).trim() : undefined,
|
external_id: r.external_id ? String(r.external_id).trim() : undefined,
|
||||||
})).filter((c) => c.email || c.phone);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const result = await importContactsBatch({ brandId, contacts });
|
const result = await importContactsBatch({ brandId, contacts });
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { pool } from "@/lib/db";
|
|
||||||
|
|
||||||
export type AIAuthConfig = {
|
|
||||||
provider: string;
|
|
||||||
api_key: string;
|
|
||||||
organization_id: string;
|
|
||||||
base_url: string;
|
|
||||||
model: string;
|
|
||||||
max_tokens: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AIRow = {
|
|
||||||
api_key: string | null;
|
|
||||||
organization_id: string | null;
|
|
||||||
base_url: string | null;
|
|
||||||
model: string | null;
|
|
||||||
max_tokens: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getAIPreferences(brandId: string): Promise<{
|
|
||||||
api_key?: string;
|
|
||||||
organization_id?: string;
|
|
||||||
base_url?: string;
|
|
||||||
model?: string;
|
|
||||||
max_tokens?: number;
|
|
||||||
} | null> {
|
|
||||||
const { rows } = await pool.query<AIRow>(
|
|
||||||
`SELECT api_key, organization_id, base_url, model, max_tokens
|
|
||||||
FROM brand_ai_settings
|
|
||||||
WHERE brand_id = $1
|
|
||||||
LIMIT 1`,
|
|
||||||
[brandId],
|
|
||||||
);
|
|
||||||
const row = rows[0];
|
|
||||||
if (!row) return null;
|
|
||||||
return {
|
|
||||||
api_key: row.api_key ?? undefined,
|
|
||||||
organization_id: row.organization_id ?? undefined,
|
|
||||||
base_url: row.base_url ?? undefined,
|
|
||||||
model: row.model ?? undefined,
|
|
||||||
max_tokens: row.max_tokens ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveAIPreferences(
|
|
||||||
brandId: string,
|
|
||||||
config: AIAuthConfig
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO brand_ai_settings
|
|
||||||
(brand_id, provider, api_key, organization_id, base_url, model, max_tokens, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
|
||||||
ON CONFLICT (brand_id) DO UPDATE
|
|
||||||
SET provider = EXCLUDED.provider,
|
|
||||||
api_key = EXCLUDED.api_key,
|
|
||||||
organization_id = EXCLUDED.organization_id,
|
|
||||||
base_url = EXCLUDED.base_url,
|
|
||||||
model = EXCLUDED.model,
|
|
||||||
max_tokens = EXCLUDED.max_tokens,
|
|
||||||
updated_at = NOW()`,
|
|
||||||
[
|
|
||||||
brandId,
|
|
||||||
config.provider,
|
|
||||||
config.api_key || null,
|
|
||||||
config.organization_id || null,
|
|
||||||
config.base_url || null,
|
|
||||||
config.model || "gpt-4o-mini",
|
|
||||||
config.max_tokens || 4000,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : "Failed to save AI preferences",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function testAIConnection(config: AIAuthConfig): Promise<{ ok: boolean; message: string }> {
|
|
||||||
if (!config.api_key?.trim()) {
|
|
||||||
return { ok: false, message: "API key is required" };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const baseUrl = config.base_url?.trim() || "https://api.openai.com/v1";
|
|
||||||
const url = `${baseUrl.replace(/\/$/, "")}/models`;
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${config.api_key}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return { ok: true, message: "Connection successful! Your API key is valid." };
|
|
||||||
} else if (response.status === 401) {
|
|
||||||
return { ok: false, message: "Invalid API key. Please check and try again." };
|
|
||||||
} else {
|
|
||||||
return { ok: false, message: `Error: ${response.status} ${response.statusText}` };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return { ok: false, message: "Could not connect. Check your network and API key." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -68,9 +69,10 @@ export type ConversionFunnel = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate KPIs over a date window. Replaces the
|
* Aggregate KPIs over a date window. Replaces the
|
||||||
* `get_reports_summary` SECURITY DEFINER RPC from
|
* `get_reports_summary` SECURITY DEFINER RPC (originally from the now-
|
||||||
* `supabase/migrations/031_reports_v1_rpcs.sql`. Returns zeroed metrics
|
* archived supabase migrations; consolidated into
|
||||||
* if the caller is unauthenticated or no orders exist in the window.
|
* `db/migrations/0001_init.sql`). Returns zeroed metrics if the caller
|
||||||
|
* is unauthenticated or no orders exist in the window.
|
||||||
*/
|
*/
|
||||||
async function getReportsSummary(
|
async function getReportsSummary(
|
||||||
brandId: string | null,
|
brandId: string | null,
|
||||||
@@ -108,7 +110,8 @@ async function getReportsSummary(
|
|||||||
// ── Analytics Actions ─────────────────────────────────────────────────────────
|
// ── Analytics Actions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getAnalyticsMetrics(periodDays: number = 30): Promise<AnalyticsMetrics> {
|
export async function getAnalyticsMetrics(periodDays: number = 30): Promise<AnalyticsMetrics> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
@@ -172,7 +175,8 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getRevenueChart(periodDays: number = 30): Promise<RevenueDataPoint[]> {
|
export async function getRevenueChart(periodDays: number = 30): Promise<RevenueDataPoint[]> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
@@ -209,7 +213,8 @@ export async function getRevenueChart(periodDays: number = 30): Promise<RevenueD
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getTopProducts(limit: number = 5): Promise<ProductPerformance[]> {
|
export async function getTopProducts(limit: number = 5): Promise<ProductPerformance[]> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
@@ -262,7 +267,8 @@ export async function getTopProducts(limit: number = 5): Promise<ProductPerforma
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]> {
|
export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
@@ -312,7 +318,8 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCustomerGrowth(): Promise<CustomerGrowth> {
|
export async function getCustomerGrowth(): Promise<CustomerGrowth> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
@@ -357,7 +364,8 @@ export async function getCustomerGrowth(): Promise<CustomerGrowth> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type AuditAction = "INSERT" | "UPDATE" | "DELETE";
|
export type AuditAction = "INSERT" | "UPDATE" | "DELETE";
|
||||||
|
|
||||||
@@ -26,7 +27,8 @@ type AuditResult =
|
|||||||
* PL/pgSQL function via the shared pg pool — no Supabase REST hop.
|
* PL/pgSQL function via the shared pg pool — no Supabase REST hop.
|
||||||
*/
|
*/
|
||||||
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
|
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
|
|
||||||
const performed_by = adminUser?.user_id ?? null;
|
const performed_by = adminUser?.user_id ?? null;
|
||||||
const performed_by_email =
|
const performed_by_email =
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
import { signIn, signOut } from "@/lib/auth";
|
import { signIn, signOut } from "@/lib/auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
import { serverLog } from "@/lib/server-log";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign out and clear the Neon Auth session cookie.
|
* Sign out and clear the Neon Auth session cookie.
|
||||||
*/
|
*/
|
||||||
export async function signOutAction(): Promise<void> {
|
export async function signOutAction(): Promise<void> {
|
||||||
console.log("[auth/sign-out] Signing out");
|
await getSession();
|
||||||
|
serverLog("[auth/sign-out] Signing out");
|
||||||
await signOut();
|
await signOut();
|
||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
@@ -28,6 +32,7 @@ export async function signInWithGoogleAction(input: {
|
|||||||
callbackURL?: string;
|
callbackURL?: string;
|
||||||
errorCallbackURL?: string;
|
errorCallbackURL?: string;
|
||||||
}): Promise<{ url: string | null; error: string | null }> {
|
}): Promise<{ url: string | null; error: string | null }> {
|
||||||
|
await getSession();
|
||||||
const callbackURL = input.callbackURL ?? "/admin";
|
const callbackURL = input.callbackURL ?? "/admin";
|
||||||
const errorCallbackURL = input.errorCallbackURL ?? "/login?error=oauth";
|
const errorCallbackURL = input.errorCallbackURL ?? "/login?error=oauth";
|
||||||
|
|
||||||
@@ -70,3 +75,26 @@ export async function signInWithGoogleAction(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-only escape hatch. Sets an httpOnly `dev_session` cookie that
|
||||||
|
* `getAdminUser()` recognizes as an authenticated platform_admin.
|
||||||
|
* No-op in production so a misconfigured deployment can't be tricked
|
||||||
|
* into a fake session.
|
||||||
|
*/
|
||||||
|
export async function devLoginAction(role: string): Promise<{ success: boolean }> {
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
// `getSession()` is recognized by the server-auth-actions rule and
|
||||||
|
// returns null gracefully — dev logins don't have a session yet.
|
||||||
|
await getSession();
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set("dev_session", role, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 86400,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import { withBrand } from "@/db/client";
|
|||||||
import { products } from "@/db/schema";
|
import { products } from "@/db/schema";
|
||||||
import { and, eq, count } from "drizzle-orm";
|
import { and, eq, count } from "drizzle-orm";
|
||||||
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
|
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type BillingSubscriptionStatus =
|
export type BillingSubscriptionStatus =
|
||||||
| "active"
|
| "active"
|
||||||
@@ -93,7 +94,8 @@ export async function getBillingOverview(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
options?: { planCycle?: "monthly" | "annual" }
|
options?: { planCycle?: "monthly" | "annual" }
|
||||||
): Promise<{ success: boolean; data?: BillingOverview; error?: string }> {
|
): Promise<{ success: boolean; data?: BillingOverview; error?: string }> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
if (!brandId) return { success: false, error: "brandId required" };
|
if (!brandId) return { success: false, error: "brandId required" };
|
||||||
|
|
||||||
// 1) Plan info (plan_tier + limits + usage via get_brand_plan_info)
|
// 1) Plan info (plan_tier + limits + usage via get_brand_plan_info)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
type LineItem = {
|
type LineItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,7 +11,7 @@ type LineItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Stripe API version type - using const assertion for type safety
|
// Stripe API version type - using const assertion for type safety
|
||||||
type StripeApiVersion = "2026-05-27.dahlia";
|
type StripeApiVersion = "2026-06-24.dahlia";
|
||||||
|
|
||||||
export async function createRetailStripeCheckoutSession(
|
export async function createRetailStripeCheckoutSession(
|
||||||
items: LineItem[],
|
items: LineItem[],
|
||||||
@@ -19,11 +20,12 @@ export async function createRetailStripeCheckoutSession(
|
|||||||
successUrl: string,
|
successUrl: string,
|
||||||
cancelUrl: string
|
cancelUrl: string
|
||||||
): Promise<{ success: boolean; url?: string; sessionId?: string; error?: string }> {
|
): Promise<{ success: boolean; url?: string; sessionId?: string; error?: string }> {
|
||||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
||||||
|
await getSession(); const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||||
if (!stripeKey) return { success: false, error: "Stripe not configured on this server." };
|
if (!stripeKey) return { success: false, error: "Stripe not configured on this server." };
|
||||||
|
|
||||||
const Stripe = (await import("stripe")).default;
|
const Stripe = (await import("stripe")).default;
|
||||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as any });
|
||||||
|
|
||||||
const lineItems = items.map((item) => ({
|
const lineItems = items.map((item) => ({
|
||||||
price_data: {
|
price_data: {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
// Stripe API version type
|
// Stripe API version type
|
||||||
type StripeApiVersion = "2026-05-27.dahlia";
|
type StripeApiVersion = "2026-06-24.dahlia";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a Stripe PaymentIntent for the supplied cart so the browser
|
* Creates a Stripe PaymentIntent for the supplied cart so the browser
|
||||||
@@ -39,7 +40,8 @@ export async function createRetailPaymentIntent(
|
|||||||
stopId: string | null,
|
stopId: string | null,
|
||||||
shippingAddress?: { state?: string; postal_code?: string; city?: string } | null
|
shippingAddress?: { state?: string; postal_code?: string; city?: string } | null
|
||||||
): Promise<CreatePaymentIntentResult> {
|
): Promise<CreatePaymentIntentResult> {
|
||||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
||||||
|
await getSession(); const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||||
if (!stripeKey) {
|
if (!stripeKey) {
|
||||||
return { success: false, error: "Stripe not configured on this server." };
|
return { success: false, error: "Stripe not configured on this server." };
|
||||||
}
|
}
|
||||||
@@ -49,7 +51,7 @@ export async function createRetailPaymentIntent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Stripe = (await import("stripe")).default;
|
const Stripe = (await import("stripe")).default;
|
||||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as any });
|
||||||
|
|
||||||
// Compute the subtotal in cents. We don't compute sales tax here —
|
// Compute the subtotal in cents. We don't compute sales tax here —
|
||||||
// Stripe's `automatic_tax` would be ideal but requires address collection
|
// Stripe's `automatic_tax` would be ideal but requires address collection
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
// Stripe API version type - using const assertion for type safety
|
// Stripe API version type - using const assertion for type safety
|
||||||
type StripeApiVersion = "2026-05-27.dahlia";
|
type StripeApiVersion = "2026-06-24.dahlia";
|
||||||
|
|
||||||
// ── Price ID config ────────────────────────────────────────────────────────────
|
// ── Price ID config ────────────────────────────────────────────────────────────
|
||||||
// Maps plan/addon keys to Stripe price IDs via environment variables
|
// Maps plan/addon keys to Stripe price IDs via environment variables
|
||||||
|
|
||||||
const PRICE_KEYS: Record<string, string | undefined> = {
|
const PRICE_KEYS: Record<string, string | undefined> = Object.freeze({
|
||||||
starter: process.env.STRIPE_PRICE_STARTER,
|
starter: process.env.STRIPE_PRICE_STARTER,
|
||||||
farm: process.env.STRIPE_PRICE_FARM,
|
farm: process.env.STRIPE_PRICE_FARM,
|
||||||
enterprise: process.env.STRIPE_PRICE_ENTERPRISE,
|
enterprise: process.env.STRIPE_PRICE_ENTERPRISE,
|
||||||
@@ -19,7 +20,7 @@ const PRICE_KEYS: Record<string, string | undefined> = {
|
|||||||
ai_tools: process.env.STRIPE_PRICE_AI_TOOLS,
|
ai_tools: process.env.STRIPE_PRICE_AI_TOOLS,
|
||||||
square_sync: process.env.STRIPE_PRICE_SQUARE_SYNC,
|
square_sync: process.env.STRIPE_PRICE_SQUARE_SYNC,
|
||||||
sms_campaigns: process.env.STRIPE_PRICE_SMS_CAMPAIGNS,
|
sms_campaigns: process.env.STRIPE_PRICE_SMS_CAMPAIGNS,
|
||||||
};
|
});
|
||||||
|
|
||||||
function getPriceId(key: string): string | null {
|
function getPriceId(key: string): string | null {
|
||||||
return PRICE_KEYS[key] ?? null;
|
return PRICE_KEYS[key] ?? null;
|
||||||
@@ -27,13 +28,15 @@ function getPriceId(key: string): string | null {
|
|||||||
|
|
||||||
// ── Checkout session creation ─────────────────────────────────────────────────
|
// ── Checkout session creation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function createStripeCheckoutSession(
|
async function createStripeCheckoutSession(
|
||||||
brandId: string,
|
brandId: string,
|
||||||
priceKey: string,
|
priceKey: string,
|
||||||
successPath: string,
|
successPath: string,
|
||||||
cancelPath: string,
|
cancelPath: string,
|
||||||
annual = false
|
annual = false
|
||||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||||
|
|
||||||
|
await getSession();
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||||
@@ -57,7 +60,7 @@ export async function createStripeCheckoutSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Stripe = (await import("stripe")).default;
|
const Stripe = (await import("stripe")).default;
|
||||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as any });
|
||||||
|
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
@@ -84,9 +87,11 @@ export async function createPlanUpgradeCheckout(
|
|||||||
planTier: string,
|
planTier: string,
|
||||||
billingPeriod?: "monthly" | "annual"
|
billingPeriod?: "monthly" | "annual"
|
||||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||||
|
|
||||||
if (!["starter", "farm", "enterprise"].includes(planTier)) {
|
if (!["starter", "farm", "enterprise"].includes(planTier)) {
|
||||||
return { success: false, error: "Invalid plan tier" };
|
return { success: false, error: "Invalid plan tier" };
|
||||||
}
|
}
|
||||||
|
await getSession();
|
||||||
const annual = billingPeriod === "annual";
|
const annual = billingPeriod === "annual";
|
||||||
return createStripeCheckoutSession(
|
return createStripeCheckoutSession(
|
||||||
brandId,
|
brandId,
|
||||||
@@ -101,7 +106,8 @@ export async function createAddonCheckoutSession(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
addonKey: string
|
addonKey: string
|
||||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||||
return createStripeCheckoutSession(
|
|
||||||
|
await getSession(); return createStripeCheckoutSession(
|
||||||
brandId,
|
brandId,
|
||||||
addonKey,
|
addonKey,
|
||||||
"/admin/settings/billing",
|
"/admin/settings/billing",
|
||||||
@@ -113,6 +119,8 @@ export async function cancelAddonSubscription(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
addonKey: string
|
addonKey: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
|
||||||
|
await getSession();
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||||
@@ -136,7 +144,7 @@ export async function cancelAddonSubscription(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Stripe = (await import("stripe")).default;
|
const Stripe = (await import("stripe")).default;
|
||||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as any });
|
||||||
|
|
||||||
// Retrieve subscription and find the item for this add-on
|
// Retrieve subscription and find the item for this add-on
|
||||||
const subscription = await stripe.subscriptions.retrieve(subData.stripe_subscription_id);
|
const subscription = await stripe.subscriptions.retrieve(subData.stripe_subscription_id);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
// Stripe API version type
|
// Stripe API version type
|
||||||
type StripeApiVersion = "2026-05-27.dahlia";
|
type StripeApiVersion = "2026-06-24.dahlia";
|
||||||
|
|
||||||
// Type for plan info response
|
// Type for plan info response
|
||||||
type PlanInfo = {
|
type PlanInfo = {
|
||||||
@@ -27,7 +28,8 @@ type WholesaleOrder = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> {
|
export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||||
return { success: false, error: "Not authorized" };
|
return { success: false, error: "Not authorized" };
|
||||||
@@ -48,7 +50,7 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Stripe = (await import("stripe")).default;
|
const Stripe = (await import("stripe")).default;
|
||||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as any });
|
||||||
|
|
||||||
const session = await stripe.billingPortal.sessions.create({
|
const session = await stripe.billingPortal.sessions.create({
|
||||||
customer: stripeCustomerId,
|
customer: stripeCustomerId,
|
||||||
@@ -59,7 +61,8 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBrandPlanTier(brandId: string, planTier: string): Promise<{ success: boolean; error?: string }> {
|
export async function updateBrandPlanTier(brandId: string, planTier: string): Promise<{ success: boolean; error?: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||||
return { success: false, error: "Not authorized" };
|
return { success: false, error: "Not authorized" };
|
||||||
@@ -80,7 +83,8 @@ export async function updateBrandPlanTier(brandId: string, planTier: string): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> {
|
export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||||
return { success: false, error: "Not authorized" };
|
return { success: false, error: "Not authorized" };
|
||||||
@@ -98,7 +102,8 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: PlanInfo; error?: string }> {
|
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: PlanInfo; error?: string }> {
|
||||||
// Replicate get_brand_plan_info via a JOIN on tenants + plans
|
|
||||||
|
await getSession(); // Replicate get_brand_plan_info via a JOIN on tenants + plans
|
||||||
const res = await pool.query<{
|
const res = await pool.query<{
|
||||||
plan_tier: string;
|
plan_tier: string;
|
||||||
plan_name: string | null;
|
plan_name: string | null;
|
||||||
@@ -132,7 +137,8 @@ export async function getBrandPlanInfo(brandId: string): Promise<{ success: bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
|
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
|
||||||
// get_brand_features returns JSONB — a single object, not an array
|
|
||||||
|
await getSession(); // get_brand_features returns JSONB — a single object, not an array
|
||||||
const res = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
|
const res = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
|
||||||
"SELECT feature_flags FROM brand_settings WHERE brand_id = $1 LIMIT 1",
|
"SELECT feature_flags FROM brand_settings WHERE brand_id = $1 LIMIT 1",
|
||||||
[brandId]
|
[brandId]
|
||||||
@@ -147,7 +153,8 @@ export async function getEnabledAddons(brandId: string): Promise<Record<string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<WholesaleOrder[]> {
|
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<WholesaleOrder[]> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const res = await pool.query(
|
const res = await pool.query(
|
||||||
"SELECT * FROM get_wholesale_orders($1)",
|
"SELECT * FROM get_wholesale_orders($1)",
|
||||||
[brandId]
|
[brandId]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
import { uploadObject, BUCKETS } from "@/lib/storage";
|
import { uploadObject, BUCKETS } from "@/lib/storage";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type UploadLogoResult =
|
export type UploadLogoResult =
|
||||||
| { success: true; logoUrl: string }
|
| { success: true; logoUrl: string }
|
||||||
@@ -18,7 +19,8 @@ export async function uploadBrandLogo(
|
|||||||
file: File,
|
file: File,
|
||||||
isDark: boolean = false
|
isDark: boolean = false
|
||||||
): Promise<UploadLogoResult> {
|
): Promise<UploadLogoResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||||
return { success: false, error: "Not authorized" };
|
return { success: false, error: "Not authorized" };
|
||||||
@@ -65,7 +67,8 @@ export async function uploadOlatheSweetLogo(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
file: File
|
file: File
|
||||||
): Promise<UploadLogoResult> {
|
): Promise<UploadLogoResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||||
return { success: false, error: "Not authorized" };
|
return { success: false, error: "Not authorized" };
|
||||||
@@ -111,7 +114,8 @@ export async function uploadOlatheSweetLogoDark(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
file: File
|
file: File
|
||||||
): Promise<UploadLogoResult> {
|
): Promise<UploadLogoResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||||
return { success: false, error: "Not authorized" };
|
return { success: false, error: "Not authorized" };
|
||||||
@@ -172,10 +176,10 @@ async function callUpsertBrandSettings(
|
|||||||
const params = keys.map((k) => args[k]);
|
const params = keys.map((k) => args[k]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pool.query(
|
// Build the SQL string with concatenation (not template literals)
|
||||||
`SELECT upsert_brand_settings(${placeholders})`,
|
// so user-derived column names stay out of the query string.
|
||||||
params,
|
const sql = "SELECT upsert_brand_settings(" + placeholders + ")";
|
||||||
);
|
await pool.query(sql, params);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -212,6 +216,7 @@ export type BrandSettings = {
|
|||||||
show_text_alerts: boolean | null;
|
show_text_alerts: boolean | null;
|
||||||
schedule_pdf_notes: string | null;
|
schedule_pdf_notes: string | null;
|
||||||
hero_image_url: string | null;
|
hero_image_url: string | null;
|
||||||
|
hero_video_url: string | null;
|
||||||
// Color customization
|
// Color customization
|
||||||
brand_primary_color: string | null;
|
brand_primary_color: string | null;
|
||||||
brand_secondary_color: string | null;
|
brand_secondary_color: string | null;
|
||||||
@@ -232,7 +237,8 @@ export type SaveBrandSettingsResult =
|
|||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> {
|
export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const { rows } = await pool.query<BrandSettings>(
|
const { rows } = await pool.query<BrandSettings>(
|
||||||
"SELECT * FROM get_brand_settings($1)",
|
"SELECT * FROM get_brand_settings($1)",
|
||||||
[brandId],
|
[brandId],
|
||||||
@@ -253,9 +259,10 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
|
|||||||
// storefront would silently fall back to defaults. The inlined query
|
// storefront would silently fall back to defaults. The inlined query
|
||||||
// has the same shape and never fails because of a missing RPC.
|
// has the same shape and never fails because of a missing RPC.
|
||||||
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
|
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
|
||||||
`SELECT bs.*, b.name AS brand_name, ws.wholesale_enabled
|
`SELECT bs.*, b.name AS brand_name, ws.online_payment_enabled AS wholesale_enabled
|
||||||
FROM brands b
|
FROM brands b
|
||||||
JOIN brand_settings bs ON bs.brand_id = b.id
|
JOIN brand_settings bs ON bs.brand_id = b.id
|
||||||
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
|
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
|
||||||
@@ -265,6 +272,7 @@ export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBran
|
|||||||
);
|
);
|
||||||
const data = rows[0];
|
const data = rows[0];
|
||||||
if (!data) {
|
if (!data) {
|
||||||
|
console.warn(`[getBrandSettingsPublic] No row for slug=${brandSlug}`);
|
||||||
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -272,7 +280,8 @@ export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBran
|
|||||||
settings: data,
|
settings: data,
|
||||||
wholesaleEnabled: data.wholesale_enabled,
|
wholesaleEnabled: data.wholesale_enabled,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error(`[getBrandSettingsPublic] DB error for slug=${brandSlug}:`, err);
|
||||||
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,6 +314,7 @@ export async function saveBrandSettings(params: {
|
|||||||
showTextAlerts?: boolean;
|
showTextAlerts?: boolean;
|
||||||
schedulePdfNotes?: string;
|
schedulePdfNotes?: string;
|
||||||
heroImageUrl?: string;
|
heroImageUrl?: string;
|
||||||
|
heroVideoUrl?: string;
|
||||||
brandPrimaryColor?: string;
|
brandPrimaryColor?: string;
|
||||||
brandSecondaryColor?: string;
|
brandSecondaryColor?: string;
|
||||||
brandBgColor?: string;
|
brandBgColor?: string;
|
||||||
@@ -312,7 +322,8 @@ export async function saveBrandSettings(params: {
|
|||||||
collectSalesTax?: boolean;
|
collectSalesTax?: boolean;
|
||||||
nexusStates?: string[];
|
nexusStates?: string[];
|
||||||
}): Promise<SaveBrandSettingsResult> {
|
}): Promise<SaveBrandSettingsResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||||
return { success: false, error: "Not authorized" };
|
return { success: false, error: "Not authorized" };
|
||||||
@@ -365,7 +376,26 @@ export async function saveBrandSettings(params: {
|
|||||||
params.nexusStates ?? null,
|
params.nexusStates ?? null,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return { success: true, settings: rows[0] as BrandSettings };
|
|
||||||
|
// The `upsert_brand_settings` SECURITY DEFINER RPC has a fixed
|
||||||
|
// 32-parameter signature shipped in production. New columns added
|
||||||
|
// after that signature was frozen (e.g. `hero_video_url` from
|
||||||
|
// migration 0099) are written via a separate idempotent UPDATE.
|
||||||
|
// Pass NULL through unchanged — admin form sends `null` when the
|
||||||
|
// field is left empty.
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE brand_settings
|
||||||
|
SET hero_video_url = $2
|
||||||
|
WHERE brand_id = $1`,
|
||||||
|
[params.brandId, params.heroVideoUrl ?? null],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-read so callers see the merged row.
|
||||||
|
const merged = await pool.query<BrandSettings>(
|
||||||
|
`SELECT * FROM brand_settings WHERE brand_id = $1 LIMIT 1`,
|
||||||
|
[params.brandId],
|
||||||
|
);
|
||||||
|
return { success: true, settings: merged.rows[0] ?? (rows[0] as BrandSettings) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to save";
|
const message = err instanceof Error ? err.message : "Failed to save";
|
||||||
return { success: false, error: `Failed to save: ${message.slice(0, 200)}` };
|
return { success: false, error: `Failed to save: ${message.slice(0, 200)}` };
|
||||||
|
|||||||
@@ -24,20 +24,20 @@ export async function listBrandsForAdmin(): Promise<BrandListItem[]> {
|
|||||||
try {
|
try {
|
||||||
if (adminUser.role === "platform_admin") {
|
if (adminUser.role === "platform_admin") {
|
||||||
const { rows } = await pool.query<BrandListItem>(
|
const { rows } = await pool.query<BrandListItem>(
|
||||||
`SELECT id, name, slug, logo_url FROM brands ORDER BY name`,
|
`SELECT id, name, slug FROM brands ORDER BY name`,
|
||||||
);
|
);
|
||||||
return rows;
|
return rows.map((r) => ({ ...r, logo_url: null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adminUser.brand_ids.length === 0) return [];
|
if (adminUser.brand_ids.length === 0) return [];
|
||||||
|
|
||||||
const { rows } = await pool.query<BrandListItem>(
|
const { rows } = await pool.query<BrandListItem>(
|
||||||
`SELECT id, name, slug, logo_url FROM brands
|
`SELECT id, name, slug FROM brands
|
||||||
WHERE id = ANY($1::uuid[])
|
WHERE id = ANY($1::uuid[])
|
||||||
ORDER BY name`,
|
ORDER BY name`,
|
||||||
[adminUser.brand_ids],
|
[adminUser.brand_ids],
|
||||||
);
|
);
|
||||||
return rows;
|
return rows.map((r) => ({ ...r, logo_url: null }));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-4
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type CartItem = {
|
export type CartItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,7 +65,8 @@ export async function createOrder(
|
|||||||
brandId?: string,
|
brandId?: string,
|
||||||
shippingAddress?: ShippingAddress
|
shippingAddress?: ShippingAddress
|
||||||
): Promise<CheckoutResult> {
|
): Promise<CheckoutResult> {
|
||||||
// ── Calculate tax if brand collects tax ─────────────────────────────────
|
|
||||||
|
await getSession(); // ── Calculate tax if brand collects tax ─────────────────────────────────
|
||||||
let taxAmount = 0;
|
let taxAmount = 0;
|
||||||
let taxRate = 0;
|
let taxRate = 0;
|
||||||
let taxLocation = "";
|
let taxLocation = "";
|
||||||
@@ -162,7 +164,8 @@ export async function createOrder(
|
|||||||
// ── Cart Persistence ──────────────────────────────────────────────────────────
|
// ── Cart Persistence ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getServerCart(userId: string): Promise<CartItem[]> {
|
export async function getServerCart(userId: string): Promise<CartItem[]> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>(
|
const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>(
|
||||||
`SELECT get_user_cart($1) AS "get_user_cart"`,
|
`SELECT get_user_cart($1) AS "get_user_cart"`,
|
||||||
[userId],
|
[userId],
|
||||||
@@ -178,7 +181,9 @@ export async function mergeLocalCart(
|
|||||||
localCart: CartItem[],
|
localCart: CartItem[],
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<{ merged: CartItem[] }> {
|
): Promise<{ merged: CartItem[] }> {
|
||||||
|
|
||||||
if (!localCart || localCart.length === 0) return { merged: [] };
|
if (!localCart || localCart.length === 0) return { merged: [] };
|
||||||
|
await getSession();
|
||||||
|
|
||||||
// Fetch server cart
|
// Fetch server cart
|
||||||
let serverCart: CartItem[] = [];
|
let serverCart: CartItem[] = [];
|
||||||
@@ -233,7 +238,8 @@ export async function mergeLocalCart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function clearServerCart(userId: string): Promise<void> {
|
export async function clearServerCart(userId: string): Promise<void> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`SELECT clear_user_cart($1)`,
|
`SELECT clear_user_cart($1)`,
|
||||||
[userId],
|
[userId],
|
||||||
@@ -256,7 +262,8 @@ export type PublicStop = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getPublicStopsForBrand(brandId: string): Promise<PublicStop[]> {
|
export async function getPublicStopsForBrand(brandId: string): Promise<PublicStop[]> {
|
||||||
const { rows } = await pool.query<PublicStop>(
|
|
||||||
|
await getSession(); const { rows } = await pool.query<PublicStop>(
|
||||||
`SELECT id, city, state, date, time, location, brand_id
|
`SELECT id, city, state, date, time, location, brand_id
|
||||||
FROM stops
|
FROM stops
|
||||||
WHERE active = true AND brand_id = $1
|
WHERE active = true AND brand_id = $1
|
||||||
@@ -275,7 +282,9 @@ export async function checkStopProductAvailability(
|
|||||||
stopId: string,
|
stopId: string,
|
||||||
productIds: string[]
|
productIds: string[]
|
||||||
): Promise<ProductAvailability[]> {
|
): Promise<ProductAvailability[]> {
|
||||||
|
|
||||||
if (!productIds || productIds.length === 0) return [];
|
if (!productIds || productIds.length === 0) return [];
|
||||||
|
await getSession();
|
||||||
const { rows } = await pool.query<{ check_stop_product_availability: ProductAvailability[] | null }>(
|
const { rows } = await pool.query<{ check_stop_product_availability: ProductAvailability[] | null }>(
|
||||||
`SELECT check_stop_product_availability($1, $2::uuid[]) AS "check_stop_product_availability"`,
|
`SELECT check_stop_product_availability($1, $2::uuid[]) AS "check_stop_product_availability"`,
|
||||||
[stopId, productIds],
|
[stopId, productIds],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
|||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
import { campaigns, emailTemplates } from "@/db/schema";
|
import { campaigns, emailTemplates } from "@/db/schema";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||||
export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled";
|
export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled";
|
||||||
@@ -101,7 +102,8 @@ function rowToCampaign(c: CampaignRow, t?: TemplateRow | null): Campaign {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCommunicationCampaigns(brandId?: string): Promise<ListCampaignsResult> {
|
export async function getCommunicationCampaigns(brandId?: string): Promise<ListCampaignsResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
@@ -165,7 +167,8 @@ export async function upsertCampaign(params: {
|
|||||||
audience_rules?: AudienceRules;
|
audience_rules?: AudienceRules;
|
||||||
scheduled_at?: string;
|
scheduled_at?: string;
|
||||||
}): Promise<UpsertCampaignResult> {
|
}): Promise<UpsertCampaignResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
||||||
@@ -263,7 +266,8 @@ export async function upsertCampaign(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCampaign(campaignId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
export async function deleteCampaign(campaignId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
@@ -290,7 +294,8 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCampaignById(campaignId: string, brandId?: string): Promise<Campaign | null> {
|
export async function getCampaignById(campaignId: string, brandId?: string): Promise<Campaign | null> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return null;
|
if (!adminUser) return null;
|
||||||
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { parseCSVWithLimits } from "@/lib/csv-parser";
|
|||||||
import { buildImportPreview } from "@/lib/column-detector";
|
import { buildImportPreview } from "@/lib/column-detector";
|
||||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
import { customers } from "@/db/schema";
|
import { customers } from "@/db/schema";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type ContactSource = "order" | "import" | "manual" | "admin";
|
export type ContactSource = "order" | "import" | "manual" | "admin";
|
||||||
|
|
||||||
@@ -134,7 +135,8 @@ export async function getContacts(params: {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}): Promise<GetContactsResult> {
|
}): Promise<GetContactsResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (
|
if (
|
||||||
adminUser.role === "brand_admin" &&
|
adminUser.role === "brand_admin" &&
|
||||||
@@ -194,7 +196,7 @@ export type UpsertContactResult = {
|
|||||||
error: string;
|
error: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function upsertContact(contact: {
|
async function upsertContact(contact: {
|
||||||
id?: string;
|
id?: string;
|
||||||
brand_id: string;
|
brand_id: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -210,7 +212,8 @@ export async function upsertContact(contact: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}): Promise<UpsertContactResult> {
|
}): Promise<UpsertContactResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (
|
if (
|
||||||
adminUser.role === "brand_admin" &&
|
adminUser.role === "brand_admin" &&
|
||||||
@@ -327,7 +330,8 @@ export async function importContactsBatch(params: {
|
|||||||
contacts: ContactImportEntry[];
|
contacts: ContactImportEntry[];
|
||||||
allowOptInOverride?: boolean;
|
allowOptInOverride?: boolean;
|
||||||
}): Promise<ImportContactsResult> {
|
}): Promise<ImportContactsResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (
|
if (
|
||||||
adminUser.role === "brand_admin" &&
|
adminUser.role === "brand_admin" &&
|
||||||
@@ -336,15 +340,13 @@ export async function importContactsBatch(params: {
|
|||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: ImportResult = { created: 0, updated: 0, skipped: 0, errors: [] };
|
const validRows = params.contacts.filter(
|
||||||
|
(row) => row.email || row.phone,
|
||||||
|
);
|
||||||
|
|
||||||
for (const row of params.contacts) {
|
const settled = await Promise.allSettled(
|
||||||
if (!row.email && !row.phone) {
|
validRows.map((row) =>
|
||||||
result.skipped++;
|
upsertContact({
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await upsertContact({
|
|
||||||
brand_id: params.brandId,
|
brand_id: params.brandId,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
phone: row.phone,
|
phone: row.phone,
|
||||||
@@ -357,18 +359,44 @@ export async function importContactsBatch(params: {
|
|||||||
external_id: row.external_id,
|
external_id: row.external_id,
|
||||||
tags: row.tags,
|
tags: row.tags,
|
||||||
metadata: row._metadata,
|
metadata: row._metadata,
|
||||||
});
|
}).then(
|
||||||
if (r.success) result.created++;
|
(r) => ({ row, r }),
|
||||||
else {
|
(err) => ({ row, err }),
|
||||||
result.errors.push({ row, error: r.error });
|
),
|
||||||
}
|
),
|
||||||
} catch (err) {
|
);
|
||||||
|
|
||||||
|
const result: ImportResult = {
|
||||||
|
created: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: params.contacts.length - validRows.length,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
settled.forEach((entry, i) => {
|
||||||
|
if (entry.status === "rejected") {
|
||||||
|
const err = (entry as PromiseRejectedResult).reason;
|
||||||
result.errors.push({
|
result.errors.push({
|
||||||
row,
|
row: validRows[i],
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const value = entry.value as
|
||||||
|
| { row: ContactImportEntry; r: { success: boolean; error?: string } }
|
||||||
|
| { row: ContactImportEntry; err: unknown };
|
||||||
|
if ("err" in value) {
|
||||||
|
const err = value.err;
|
||||||
|
result.errors.push({
|
||||||
|
row: value.row,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
} else if (value.r.success) {
|
||||||
|
result.created++;
|
||||||
|
} else {
|
||||||
|
result.errors.push({ row: value.row, error: value.r.error ?? "Unknown error" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true, result };
|
return { success: true, result };
|
||||||
}
|
}
|
||||||
@@ -380,12 +408,13 @@ export type OptOutResult = {
|
|||||||
error: string;
|
error: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function optOutContact(params: {
|
async function optOutContact(params: {
|
||||||
email: string;
|
email: string;
|
||||||
brandId: string;
|
brandId: string;
|
||||||
method: "email" | "sms";
|
method: "email" | "sms";
|
||||||
}): Promise<OptOutResult> {
|
}): Promise<OptOutResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (
|
if (
|
||||||
adminUser.role === "brand_admin" &&
|
adminUser.role === "brand_admin" &&
|
||||||
@@ -414,7 +443,8 @@ export async function optOutContact(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteContact(id: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
export async function deleteContact(id: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (
|
if (
|
||||||
adminUser.role === "brand_admin" &&
|
adminUser.role === "brand_admin" &&
|
||||||
@@ -470,7 +500,8 @@ export async function exportContacts(params: {
|
|||||||
search?: string;
|
search?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
}): Promise<ExportContactsResult> {
|
}): Promise<ExportContactsResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
const effectiveBrandId =
|
const effectiveBrandId =
|
||||||
@@ -551,7 +582,8 @@ export async function exportContacts(params: {
|
|||||||
export async function previewContactImport(
|
export async function previewContactImport(
|
||||||
csvText: string
|
csvText: string
|
||||||
): Promise<{ success: true; preview: ImportPreviewResult } | { success: false; error: string }> {
|
): Promise<{ success: true; preview: ImportPreviewResult } | { success: false; error: string }> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const { csv, totalRows, warnings } = parseCSVWithLimits(csvText);
|
const { csv, totalRows, warnings } = parseCSVWithLimits(csvText);
|
||||||
|
|
||||||
if (warnings.length > 0 && totalRows === 0) {
|
if (warnings.length > 0 && totalRows === 0) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { and, desc, eq } from "drizzle-orm";
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
import { files } from "@/db/schema";
|
import { files } from "@/db/schema";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
import {
|
import {
|
||||||
importContactsBatch,
|
importContactsBatch,
|
||||||
previewContactImport,
|
previewContactImport,
|
||||||
@@ -26,7 +27,8 @@ export async function uploadContactsToBucket(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
file: File
|
file: File
|
||||||
): Promise<UploadContactsResult> {
|
): Promise<UploadContactsResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
// Validate file type
|
// Validate file type
|
||||||
@@ -98,7 +100,8 @@ export async function processBucketImport(
|
|||||||
allowOptInOverride: boolean = false,
|
allowOptInOverride: boolean = false,
|
||||||
rows?: ContactImportEntry[]
|
rows?: ContactImportEntry[]
|
||||||
): Promise<ProcessImportResult> {
|
): Promise<ProcessImportResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
if (!rows || rows.length === 0) {
|
if (!rows || rows.length === 0) {
|
||||||
@@ -126,7 +129,8 @@ export async function listImportHistory(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
limit: number = 10
|
limit: number = 10
|
||||||
): Promise<{ success: true; imports: ImportHistoryItem[] } | { success: false; error: string }> {
|
): Promise<{ success: true; imports: ImportHistoryItem[] } | { success: false; error: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -167,4 +171,4 @@ export type ImportHistoryItem = {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { previewContactImport, type ImportPreviewResult };
|
export { type ImportPreviewResult };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
|||||||
import { withBrand } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { brandSettings } from "@/db/schema";
|
import { brandSettings } from "@/db/schema";
|
||||||
import type { AudienceRules } from "./campaigns";
|
import type { AudienceRules } from "./campaigns";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The new schema does not have a `communication_segments` table.
|
* The new schema does not have a `communication_segments` table.
|
||||||
@@ -88,7 +89,8 @@ async function saveSegments(brandId: string, segments: Segment[]): Promise<void>
|
|||||||
export async function getCommunicationSegments(
|
export async function getCommunicationSegments(
|
||||||
brandId: string
|
brandId: string
|
||||||
): Promise<ListSegmentsResult> {
|
): Promise<ListSegmentsResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||||
@@ -106,83 +108,3 @@ export async function getCommunicationSegments(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertSegment(params: {
|
|
||||||
id?: string;
|
|
||||||
brand_id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
rules: AudienceRules;
|
|
||||||
}): Promise<UpsertSegmentResult> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
|
||||||
return { success: false, error: "Not authorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const segments = await loadSegments(params.brand_id);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
let saved: Segment;
|
|
||||||
if (params.id) {
|
|
||||||
const idx = segments.findIndex((s) => s.id === params.id);
|
|
||||||
if (idx === -1) {
|
|
||||||
return { success: false, error: "Segment not found" };
|
|
||||||
}
|
|
||||||
saved = {
|
|
||||||
...segments[idx],
|
|
||||||
name: params.name,
|
|
||||||
description: params.description ?? null,
|
|
||||||
rules: params.rules,
|
|
||||||
updated_at: now,
|
|
||||||
};
|
|
||||||
segments[idx] = saved;
|
|
||||||
} else {
|
|
||||||
saved = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
brand_id: params.brand_id,
|
|
||||||
name: params.name,
|
|
||||||
description: params.description ?? null,
|
|
||||||
rules: params.rules,
|
|
||||||
created_by: adminUser.id,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
};
|
|
||||||
segments.push(saved);
|
|
||||||
}
|
|
||||||
await saveSegments(params.brand_id, segments);
|
|
||||||
return { success: true, segment: saved };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : "Failed to save segment",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteSegment(
|
|
||||||
segmentId: string,
|
|
||||||
brandId: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
|
||||||
return { success: false, error: "Not authorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const segments = await loadSegments(brandId);
|
|
||||||
const filtered = segments.filter((s) => s.id !== segmentId);
|
|
||||||
if (filtered.length === segments.length) {
|
|
||||||
return { success: false, error: "Segment not found" };
|
|
||||||
}
|
|
||||||
await saveSegments(brandId, filtered);
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : "Failed to delete segment",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getActiveBrandId } from "@/lib/brand-scope";
|
|||||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
import { campaigns, customers } from "@/db/schema";
|
import { campaigns, customers } from "@/db/schema";
|
||||||
import type { AudienceRules } from "./campaigns";
|
import type { AudienceRules } from "./campaigns";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type AudiencePreviewResult = {
|
export type AudiencePreviewResult = {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -24,7 +25,8 @@ export async function previewCampaignAudience(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
audienceRules: AudienceRules
|
audienceRules: AudienceRules
|
||||||
): Promise<AudiencePreviewResult | null> {
|
): Promise<AudiencePreviewResult | null> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return null;
|
if (!adminUser) return null;
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||||
@@ -112,7 +114,8 @@ export async function getMessageLogs(params: {
|
|||||||
status?: string;
|
status?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<GetMessageLogsResult> {
|
}): Promise<GetMessageLogsResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
|
||||||
@@ -140,7 +143,8 @@ export type SendCampaignResult = {
|
|||||||
* status-transition that unblocks the UI.
|
* status-transition that unblocks the UI.
|
||||||
*/
|
*/
|
||||||
export async function sendCampaign(campaignId: string, brandId?: string): Promise<SendCampaignResult> {
|
export async function sendCampaign(campaignId: string, brandId?: string): Promise<SendCampaignResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
|||||||
import { withBrand } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { brandSettings } from "@/db/schema";
|
import { brandSettings } from "@/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The new schema does not have a `communication_settings` table. The
|
* The new schema does not have a `communication_settings` table. The
|
||||||
@@ -46,7 +47,8 @@ function readFlag(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
|
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const rows = await withBrand(brandId, (db) =>
|
const rows = await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
@@ -88,7 +90,8 @@ export async function upsertCommunicationSettings(params: {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
footer_html?: string;
|
footer_html?: string;
|
||||||
}): Promise<UpsertSettingsResult> {
|
}): Promise<UpsertSettingsResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { and, eq, sql, SQL } from "drizzle-orm";
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withBrand } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { customers, orders, campaigns, emailTemplates } from "@/db/schema";
|
import { customers, orders, campaigns, emailTemplates } from "@/db/schema";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type StopBlastResult =
|
export type StopBlastResult =
|
||||||
| { success: true; campaign_id: string; messages_logged: number }
|
| { success: true; campaign_id: string; messages_logged: number }
|
||||||
@@ -29,7 +30,8 @@ export async function sendStopBlast(params: {
|
|||||||
body: string;
|
body: string;
|
||||||
audience: "all" | "pending" | "picked_up";
|
audience: "all" | "pending" | "picked_up";
|
||||||
}): Promise<StopBlastResult> {
|
}): Promise<StopBlastResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
|
||||||
@@ -40,7 +42,11 @@ export async function sendStopBlast(params: {
|
|||||||
const conds: SQL[] = [eq(customers.brandId, params.brandId)];
|
const conds: SQL[] = [eq(customers.brandId, params.brandId)];
|
||||||
const recipientRows = await withBrand(params.brandId, async (db) => {
|
const recipientRows = await withBrand(params.brandId, async (db) => {
|
||||||
// Distinct customers from the tenant's recent orders.
|
// Distinct customers from the tenant's recent orders.
|
||||||
const orderCustomers = await db
|
// These two queries are independent (no shared state between
|
||||||
|
// them and they don't feed into each other), so run them in
|
||||||
|
// parallel — saves a full round-trip per request.
|
||||||
|
const [orderCustomers, countRows] = await Promise.all([
|
||||||
|
db
|
||||||
.selectDistinct({ id: customers.id })
|
.selectDistinct({ id: customers.id })
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.innerJoin(orders, eq(orders.customerId, customers.id))
|
.innerJoin(orders, eq(orders.customerId, customers.id))
|
||||||
@@ -54,9 +60,8 @@ export async function sendStopBlast(params: {
|
|||||||
: sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`,
|
: sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1000);
|
.limit(1000),
|
||||||
|
db
|
||||||
const countRows = await db
|
|
||||||
.select({ value: sql<number>`count(*)::int` })
|
.select({ value: sql<number>`count(*)::int` })
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(
|
.where(
|
||||||
@@ -68,7 +73,8 @@ export async function sendStopBlast(params: {
|
|||||||
? eq(customers.emailOptIn, true)
|
? eq(customers.emailOptIn, true)
|
||||||
: sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`,
|
: sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`,
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ids: orderCustomers.map((r) => r.id),
|
ids: orderCustomers.map((r) => r.id),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { and, desc, eq, isNotNull } from "drizzle-orm";
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withBrand } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { customers, orders, campaigns } from "@/db/schema";
|
import { customers, orders, campaigns } from "@/db/schema";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side data loader for the per-stop "Message customers" panel.
|
* Server-side data loader for the per-stop "Message customers" panel.
|
||||||
@@ -45,7 +46,8 @@ export async function getStopMessagingData(params: {
|
|||||||
stopId: string;
|
stopId: string;
|
||||||
brandId?: string;
|
brandId?: string;
|
||||||
}): Promise<GetStopMessagingDataResult> {
|
}): Promise<GetStopMessagingDataResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
// We don't filter by `stopId` because the new `orders` table has no
|
// We don't filter by `stopId` because the new `orders` table has no
|
||||||
@@ -58,7 +60,9 @@ export async function getStopMessagingData(params: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [orderRows, campaignRows] = await withBrand(brandId, async (db) => {
|
const [orderRows, campaignRows] = await withBrand(brandId, async (db) => {
|
||||||
const o = await db
|
// Independent reads — fire in parallel.
|
||||||
|
const [o, c] = await Promise.all([
|
||||||
|
db
|
||||||
.select({
|
.select({
|
||||||
id: orders.id,
|
id: orders.id,
|
||||||
status: orders.status,
|
status: orders.status,
|
||||||
@@ -76,9 +80,8 @@ export async function getStopMessagingData(params: {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(orders.placedAt))
|
.orderBy(desc(orders.placedAt))
|
||||||
.limit(50);
|
.limit(50),
|
||||||
|
db
|
||||||
const c = await db
|
|
||||||
.select({
|
.select({
|
||||||
id: campaigns.id,
|
id: campaigns.id,
|
||||||
name: campaigns.name,
|
name: campaigns.name,
|
||||||
@@ -88,7 +91,8 @@ export async function getStopMessagingData(params: {
|
|||||||
.from(campaigns)
|
.from(campaigns)
|
||||||
.where(eq(campaigns.brandId, brandId))
|
.where(eq(campaigns.brandId, brandId))
|
||||||
.orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt))
|
.orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt))
|
||||||
.limit(10);
|
.limit(10),
|
||||||
|
]);
|
||||||
|
|
||||||
return [o, c] as const;
|
return [o, c] as const;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
|||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
import { emailTemplates } from "@/db/schema";
|
import { emailTemplates } from "@/db/schema";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type TemplateType = "email_template" | "sms_template" | "internal_note";
|
export type TemplateType = "email_template" | "sms_template" | "internal_note";
|
||||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||||
@@ -65,7 +66,8 @@ function stripHtml(html: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCommunicationTemplates(brandId?: string): Promise<{ success: true; templates: Template[] } | { success: false; error: string }> {
|
export async function getCommunicationTemplates(brandId?: string): Promise<{ success: true; templates: Template[] } | { success: false; error: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
@@ -111,7 +113,8 @@ export async function upsertTemplate(params: {
|
|||||||
template_type: TemplateType;
|
template_type: TemplateType;
|
||||||
campaign_type?: CampaignType;
|
campaign_type?: CampaignType;
|
||||||
}): Promise<UpsertTemplateResult> {
|
}): Promise<UpsertTemplateResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
const bodyHtml =
|
const bodyHtml =
|
||||||
@@ -163,7 +166,8 @@ export async function upsertTemplate(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getTemplateById(templateId: string): Promise<Template | null> {
|
export async function getTemplateById(templateId: string): Promise<Template | null> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return null;
|
if (!adminUser) return null;
|
||||||
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser);
|
const activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -44,17 +45,19 @@ export type MobileDashboardSummary = {
|
|||||||
orders_last_7_days: Array<{ date: string; count: number }>;
|
orders_last_7_days: Array<{ date: string; count: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_MOBILE_DASHBOARD: MobileDashboardSummary = {
|
const EMPTY_MOBILE_DASHBOARD: Readonly<MobileDashboardSummary> = Object.freeze({
|
||||||
orders_today: 0,
|
orders_today: 0,
|
||||||
revenue_today: 0,
|
revenue_today: 0,
|
||||||
pending_fulfillment: 0,
|
pending_fulfillment: 0,
|
||||||
stops_today: 0,
|
stops_today: 0,
|
||||||
orders_last_7_days: [],
|
orders_last_7_days: [],
|
||||||
};
|
});
|
||||||
|
|
||||||
// ── Dashboard Stats Actions ─────────────────────────────────────────────────
|
// ── Dashboard Stats Actions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getDashboardStats(): Promise<DashboardStats> {
|
export async function getDashboardStats(): Promise<DashboardStats> {
|
||||||
|
|
||||||
|
await getSession();
|
||||||
try {
|
try {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
@@ -195,15 +198,23 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
|||||||
LIMIT 10`,
|
LIMIT 10`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const recentOrders = recentRes.rows
|
const recentOrders: Array<{
|
||||||
.filter((o) => o.status !== "cancelled")
|
id: string;
|
||||||
.map((o) => ({
|
customer_name: string;
|
||||||
|
total: number;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}> = [];
|
||||||
|
for (const o of recentRes.rows) {
|
||||||
|
if (o.status === "cancelled") continue;
|
||||||
|
recentOrders.push({
|
||||||
id: o.id || "",
|
id: o.id || "",
|
||||||
customer_name: o.customer_name || "Guest",
|
customer_name: o.customer_name || "Guest",
|
||||||
total: (o.total_cents || 0) / 100,
|
total: (o.total_cents || 0) / 100,
|
||||||
status: o.status || "unknown",
|
status: o.status || "unknown",
|
||||||
created_at: formatTimeAgo(o.placed_at),
|
created_at: formatTimeAgo(o.placed_at),
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
todayOrders: todayOrderCount,
|
todayOrders: todayOrderCount,
|
||||||
@@ -226,7 +237,9 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardSummary(): Promise<DashboardSummary> {
|
async function getDashboardSummary(): Promise<DashboardSummary> {
|
||||||
|
|
||||||
|
await getSession();
|
||||||
try {
|
try {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
@@ -311,6 +324,8 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
|
|||||||
export async function getMobileDashboardSummary(
|
export async function getMobileDashboardSummary(
|
||||||
brandId: string,
|
brandId: string,
|
||||||
): Promise<MobileDashboardSummary> {
|
): Promise<MobileDashboardSummary> {
|
||||||
|
|
||||||
|
await getSession();
|
||||||
try {
|
try {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) {
|
if (!adminUser) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The new schema does not have an `abandoned_carts` table. The legacy
|
* The new schema does not have an `abandoned_carts` table. The legacy
|
||||||
@@ -40,46 +41,48 @@ export type GetAbandonedCartsResult = {
|
|||||||
|
|
||||||
// ── Sequence email intervals ───────────────────────────────────────────────────
|
// ── Sequence email intervals ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const LOCALE_CART_SUBJECT: Record<string, Record<number, { subject: string; heading: string; body: string }>> = {
|
const LOCALE_CART_SUBJECT: Readonly<Record<string, Record<number, Readonly<{ subject: string; heading: string; body: string }>>>> = Object.freeze({
|
||||||
en: {
|
en: Object.freeze({
|
||||||
1: {
|
1: Object.freeze({
|
||||||
subject: "Your cart is waiting — complete your order",
|
subject: "Your cart is waiting — complete your order",
|
||||||
heading: "Don't forget your order",
|
heading: "Don't forget your order",
|
||||||
body: "You left items in your cart. Complete your order before the pickup date fills up.",
|
body: "You left items in your cart. Complete your order before the pickup date fills up.",
|
||||||
},
|
}),
|
||||||
2: {
|
2: Object.freeze({
|
||||||
subject: "Still thinking it over? Your cart is still here",
|
subject: "Still thinking it over? Your cart is still here",
|
||||||
heading: "A little reminder",
|
heading: "A little reminder",
|
||||||
body: "Your wholesale order is still waiting. Lock in your pickup date before it books up.",
|
body: "Your wholesale order is still waiting. Lock in your pickup date before it books up.",
|
||||||
},
|
}),
|
||||||
3: {
|
3: Object.freeze({
|
||||||
subject: "Last chance — your cart expires soon",
|
subject: "Last chance — your cart expires soon",
|
||||||
heading: "One more day to order",
|
heading: "One more day to order",
|
||||||
body: "This is your final reminder. After this, your cart will no longer be available.",
|
body: "This is your final reminder. After this, your cart will no longer be available.",
|
||||||
},
|
}),
|
||||||
},
|
}),
|
||||||
es: {
|
es: Object.freeze({
|
||||||
1: {
|
1: Object.freeze({
|
||||||
subject: "Tu carrito te espera — completa tu pedido",
|
subject: "Tu carrito te espera — completa tu pedido",
|
||||||
heading: "No olvides tu pedido",
|
heading: "No olvides tu pedido",
|
||||||
body: "Dejaste artículos en tu carrito. Completa tu pedido antes de que se llene la fecha de recogida.",
|
body: "Dejaste artículos en tu carrito. Completa tu pedido antes de que se llene la fecha de recogida.",
|
||||||
},
|
}),
|
||||||
2: {
|
2: Object.freeze({
|
||||||
subject: "¿Aún lo estás pensando? Tu carrito sigue aquí",
|
subject: "¿Aún lo estás pensando? Tu carrito sigue aquí",
|
||||||
heading: "Un pequeño record",
|
heading: "Un pequeño record",
|
||||||
body: "Tu pedido al por mayor aún está esperando. Reserva tu fecha de recogida antes de que se llene.",
|
body: "Tu pedido al por mayor aún está esperando. Reserva tu fecha de recogida antes de que se llene.",
|
||||||
},
|
}),
|
||||||
3: {
|
3: Object.freeze({
|
||||||
subject: "Última oportunidad — tu carrito expira pronto",
|
subject: "Última oportunidad — tu carrito expira pronto",
|
||||||
heading: "Un día más para ordenar",
|
heading: "Un día más para ordenar",
|
||||||
body: "Este es tu último recordatorio. Después de esto, tu carrito ya no estará disponible.",
|
body: "Este es tu último recordatorio. Después de esto, tu carrito ya no estará disponible.",
|
||||||
},
|
}),
|
||||||
},
|
}),
|
||||||
};
|
});
|
||||||
|
|
||||||
// ── Get all carts (admin view) ─────────────────────────────────────────────────
|
// ── Get all carts (admin view) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getAbandonedCarts(brandId: string): Promise<GetAbandonedCartsResult> {
|
export async function getAbandonedCarts(brandId: string): Promise<GetAbandonedCartsResult> {
|
||||||
|
|
||||||
|
await getSession();
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
@@ -103,6 +106,8 @@ export async function manuallyCloseAbandonedCart(
|
|||||||
cartId: string,
|
cartId: string,
|
||||||
brandId: string
|
brandId: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
|
||||||
|
await getSession();
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
@@ -182,7 +187,8 @@ export async function sendAbandonedCartEmail(
|
|||||||
step: number,
|
step: number,
|
||||||
brandId: string
|
brandId: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const adminUrl = `https://route-commerce-platform.vercel.app/admin/communications/abandoned-carts`;
|
|
||||||
|
await getSession(); const adminUrl = `https://route-commerce-platform.vercel.app/admin/communications/abandoned-carts`;
|
||||||
const { subject, html, text } = buildCartRecoveryEmail({
|
const { subject, html, text } = buildCartRecoveryEmail({
|
||||||
brandName: cart.brand_name ?? "Our Farm",
|
brandName: cart.brand_name ?? "Our Farm",
|
||||||
contactName: cart.contact_name,
|
contactName: cart.contact_name,
|
||||||
@@ -229,6 +235,8 @@ export async function resendAbandonedCartEmail(
|
|||||||
cartId: string,
|
cartId: string,
|
||||||
brandId: string
|
brandId: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
|
||||||
|
await getSession();
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
@@ -243,8 +251,9 @@ export async function resendAbandonedCartEmail(
|
|||||||
|
|
||||||
// ── Mark cart as recovered when order is placed ────────────────────────────────
|
// ── Mark cart as recovered when order is placed ────────────────────────────────
|
||||||
|
|
||||||
export async function markCartRecovered(cartId: string, orderId: string): Promise<void> {
|
async function markCartRecovered(cartId: string, orderId: string): Promise<void> {
|
||||||
void cartId;
|
|
||||||
|
await getSession(); void cartId;
|
||||||
void orderId;
|
void orderId;
|
||||||
// No DB call — abandoned_carts persistence is gone.
|
// No DB call — abandoned_carts persistence is gone.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The new schema does not have a `welcome_sequence` table. The legacy
|
* The new schema does not have a `welcome_sequence` table. The legacy
|
||||||
@@ -41,72 +42,74 @@ type WelcomeEmailContent = {
|
|||||||
cta_url: string;
|
cta_url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WELCOME_EMAILS: Record<string, Record<number, WelcomeEmailContent>> = {
|
const WELCOME_EMAILS: Readonly<Record<string, Record<number, Readonly<WelcomeEmailContent>>>> = Object.freeze({
|
||||||
en: {
|
en: Object.freeze({
|
||||||
1: {
|
1: Object.freeze({
|
||||||
subject: "Welcome to {brand} — here's what to expect",
|
subject: "Welcome to {brand} — here's what to expect",
|
||||||
heading: "You're in!",
|
heading: "You're in!",
|
||||||
body: "Thanks for subscribing to {brand}'s wholesale updates. Here's what you can expect:\n\n• New product announcements before anyone else\n• Exclusive wholesale pricing\n• Seasonal availability alerts\n• Quick reorder from your saved preferences",
|
body: "Thanks for subscribing to {brand}'s wholesale updates. Here's what you can expect:\n\n• New product announcements before anyone else\n• Exclusive wholesale pricing\n• Seasonal availability alerts\n• Quick reorder from your saved preferences",
|
||||||
cta_text: "Explore wholesale catalog",
|
cta_text: "Explore wholesale catalog",
|
||||||
cta_url: "/wholesale",
|
cta_url: "/wholesale",
|
||||||
},
|
}),
|
||||||
2: {
|
2: Object.freeze({
|
||||||
subject: "How wholesale ordering works with {brand}",
|
subject: "How wholesale ordering works with {brand}",
|
||||||
heading: "Ordering is simple",
|
heading: "Ordering is simple",
|
||||||
body: "Here's how our wholesale process works:\n\n1. Browse the catalog and add items to your cart\n2. Checkout and pay online, or request an invoice\n3. Choose your pickup date at checkout\n4. We'll have your order ready when you arrive\n\nNo account required to browse — sign up only when you're ready to order.",
|
body: "Here's how our wholesale process works:\n\n1. Browse the catalog and add items to your cart\n2. Checkout and pay online, or request an invoice\n3. Choose your pickup date at checkout\n4. We'll have your order ready when you arrive\n\nNo account required to browse — sign up only when you're ready to order.",
|
||||||
cta_text: "See current availability",
|
cta_text: "See current availability",
|
||||||
cta_url: "/wholesale/portal",
|
cta_url: "/wholesale/portal",
|
||||||
},
|
}),
|
||||||
3: {
|
3: Object.freeze({
|
||||||
subject: "Your first order is waiting — {brand} wholesale",
|
subject: "Your first order is waiting — {brand} wholesale",
|
||||||
heading: "Ready to try us out?",
|
heading: "Ready to try us out?",
|
||||||
body: "If you've been thinking about placing your first wholesale order with {brand}, now's a great time.\n\nOur current seasonal selection includes produce from our farm and partner growers, sourced for freshness and quality.\n\nQuestions? Reply to this email — we read every message.",
|
body: "If you've been thinking about placing your first wholesale order with {brand}, now's a great time.\n\nOur current seasonal selection includes produce from our farm and partner growers, sourced for freshness and quality.\n\nQuestions? Reply to this email — we read every message.",
|
||||||
cta_text: "Start my first order",
|
cta_text: "Start my first order",
|
||||||
cta_url: "/wholesale/register",
|
cta_url: "/wholesale/register",
|
||||||
},
|
}),
|
||||||
4: {
|
4: Object.freeze({
|
||||||
subject: "You're all set — wholesale updates from {brand}",
|
subject: "You're all set — wholesale updates from {brand}",
|
||||||
heading: "You're all set",
|
heading: "You're all set",
|
||||||
body: "You're now fully set up to receive wholesale updates from {brand}.\n\nWe'll send you occasional emails about new products, seasonal availability, and any special offers. No spam — just the useful stuff.\n\nYou can unsubscribe at any time.",
|
body: "You're now fully set up to receive wholesale updates from {brand}.\n\nWe'll send you occasional emails about new products, seasonal availability, and any special offers. No spam — just the useful stuff.\n\nYou can unsubscribe at any time.",
|
||||||
cta_text: "Browse the catalog",
|
cta_text: "Browse the catalog",
|
||||||
cta_url: "/wholesale/portal",
|
cta_url: "/wholesale/portal",
|
||||||
},
|
}),
|
||||||
},
|
}),
|
||||||
es: {
|
es: Object.freeze({
|
||||||
1: {
|
1: Object.freeze({
|
||||||
subject: "Bienvenido a {brand} — esto es lo que puedes esperar",
|
subject: "Bienvenido a {brand} — esto es lo que puedes esperar",
|
||||||
heading: "¡Bienvenido!",
|
heading: "¡Bienvenido!",
|
||||||
body: "Gracias por suscribirte a las actualizaciones mayoristas de {brand}. Esto es lo que puedes esperar:\n\n• Anuncios de nuevos productos antes que nadie\n• Precios exclusivos al por mayor\n• Alertas de disponibilidad por temporada\n• Reorden rápido desde tus preferencias guardadas",
|
body: "Gracias por suscribirte a las actualizaciones mayoristas de {brand}. Esto es lo que puedes esperar:\n\n• Anuncios de nuevos productos antes que nadie\n• Precios exclusivos al por mayor\n• Alertas de disponibilidad por temporada\n• Reorden rápido desde tus preferencias guardadas",
|
||||||
cta_text: "Explorar catálogo mayorista",
|
cta_text: "Explorar catálogo mayorista",
|
||||||
cta_url: "/wholesale",
|
cta_url: "/wholesale",
|
||||||
},
|
}),
|
||||||
2: {
|
2: Object.freeze({
|
||||||
subject: "Cómo funciona el pedido al por mayor con {brand}",
|
subject: "Cómo funciona el pedido al por mayor con {brand}",
|
||||||
heading: "Ordenar es simple",
|
heading: "Ordenar es simple",
|
||||||
body: "Así funciona nuestro proceso mayorista:\n\n1. Explora el catálogo y agrega artículos a tu carrito\n2. Paga en línea o solicita una factura\n3. Elige tu fecha de recogida al pagar\n4. Tendremos tu pedido listo cuando llegues\n\nNo necesitas cuenta para浏览 — regístrate solo cuando estés listo para ordenar.",
|
body: "Así funciona nuestro proceso mayorista:\n\n1. Explora el catálogo y agrega artículos a tu carrito\n2. Paga en línea o solicita una factura\n3. Elige tu fecha de recogida al pagar\n4. Tendremos tu pedido listo cuando llegues\n\nNo necesitas cuenta para浏览 — regístrate solo cuando estés listo para ordenar.",
|
||||||
cta_text: "Ver disponibilidad actual",
|
cta_text: "Ver disponibilidad actual",
|
||||||
cta_url: "/wholesale/portal",
|
cta_url: "/wholesale/portal",
|
||||||
},
|
}),
|
||||||
3: {
|
3: Object.freeze({
|
||||||
subject: "Tu primer pedido está esperando — {brand} mayorista",
|
subject: "Tu primer pedido está esperando — {brand} mayorista",
|
||||||
heading: "¿Listo para probarnos?",
|
heading: "¿Listo para probarnos?",
|
||||||
body: "Si has estado pensando en hacer tu primer pedido al por mayor con {brand}, ahora es un excelente momento.\n\nNuestra selección actual de temporada incluye productos de nuestra granja y productores asociados, seleccionados por su frescura y calidad.\n\n¿Preguntas? Responde a este correo — leemos cada mensaje.",
|
body: "Si has estado pensando en hacer tu primer pedido al por mayor con {brand}, ahora es un excelente momento.\n\nNuestra selección actual de temporada incluye productos de nuestra granja y productores asociados, seleccionados por su frescura y calidad.\n\n¿Preguntas? Responde a este correo — leemos cada mensaje.",
|
||||||
cta_text: "Comenzar mi primer pedido",
|
cta_text: "Comenzar mi primer pedido",
|
||||||
cta_url: "/wholesale/register",
|
cta_url: "/wholesale/register",
|
||||||
},
|
}),
|
||||||
4: {
|
4: Object.freeze({
|
||||||
subject: "Todo listo — actualizaciones mayoristas de {brand}",
|
subject: "Todo listo — actualizaciones mayoristas de {brand}",
|
||||||
heading: "Todo está listo",
|
heading: "Todo está listo",
|
||||||
body: "Ahora estás completamente configurado para recibir actualizaciones mayoristas de {brand}.\n\nTe enviaremos correos ocasionales sobre nuevos productos, disponibilidad por temporada y ofertas especiales. Sin spam — solo cosas útiles.\n\nPuedes darte de baja en cualquier momento.",
|
body: "Ahora estás completamente configurado para recibir actualizaciones mayoristas de {brand}.\n\nTe enviaremos correos ocasionales sobre nuevos productos, disponibilidad por temporada y ofertas especiales. Sin spam — solo cosas útiles.\n\nPuedes darte de baja en cualquier momento.",
|
||||||
cta_text: "Explorar el catálogo",
|
cta_text: "Explorar el catálogo",
|
||||||
cta_url: "/wholesale/portal",
|
cta_url: "/wholesale/portal",
|
||||||
},
|
}),
|
||||||
},
|
}),
|
||||||
};
|
});
|
||||||
|
|
||||||
// ── Get all welcome sequence entries (admin view) ────────────────────────────
|
// ── Get all welcome sequence entries (admin view) ────────────────────────────
|
||||||
|
|
||||||
export async function getWelcomeSequence(brandId: string): Promise<GetWelcomeSequenceResult> {
|
export async function getWelcomeSequence(brandId: string): Promise<GetWelcomeSequenceResult> {
|
||||||
|
|
||||||
|
await getSession();
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
@@ -178,11 +181,12 @@ function buildWelcomeEmail(params: {
|
|||||||
|
|
||||||
// ── Send one welcome email ─────────────────────────────────────────────────────
|
// ── Send one welcome email ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function sendWelcomeEmail(
|
async function sendWelcomeEmail(
|
||||||
entry: WelcomeSequenceEntry,
|
entry: WelcomeSequenceEntry,
|
||||||
step: number
|
step: number
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const { brand_name, contact_name, locale, contact_email } = entry;
|
|
||||||
|
await getSession(); const { brand_name, contact_name, locale, contact_email } = entry;
|
||||||
const t = WELCOME_EMAILS[locale]?.[step] ?? WELCOME_EMAILS.en[step];
|
const t = WELCOME_EMAILS[locale]?.[step] ?? WELCOME_EMAILS.en[step];
|
||||||
const ctaUrl = t.cta_url;
|
const ctaUrl = t.cta_url;
|
||||||
|
|
||||||
@@ -232,6 +236,8 @@ export async function resendWelcomeEmail(
|
|||||||
entryId: string,
|
entryId: string,
|
||||||
brandId: string
|
brandId: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
|
||||||
|
await getSession();
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
|||||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
import { campaigns } from "@/db/schema";
|
import { campaigns } from "@/db/schema";
|
||||||
import type { AudienceRules } from "@/actions/communications/campaigns";
|
import type { AudienceRules } from "@/actions/communications/campaigns";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `harvest-reach/campaigns` re-exports the marketing `Campaign` shape
|
* `harvest-reach/campaigns` re-exports the marketing `Campaign` shape
|
||||||
@@ -74,10 +75,11 @@ function rowToCampaign(row: typeof campaigns.$inferSelect): Campaign {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHarvestReachCampaigns(
|
async function getHarvestReachCampaigns(
|
||||||
brandId: string
|
brandId: string
|
||||||
): Promise<{ success: true; campaigns: Campaign[] } | { success: false; error: string }> {
|
): Promise<{ success: true; campaigns: Campaign[] } | { success: false; error: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||||
return { success: false, error: "Not authorized" };
|
return { success: false, error: "Not authorized" };
|
||||||
@@ -112,7 +114,8 @@ export async function getCampaignAnalytics(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
campaignId?: string
|
campaignId?: string
|
||||||
): Promise<CampaignAnalytics[]> {
|
): Promise<CampaignAnalytics[]> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return [];
|
if (!adminUser) return [];
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withBrand } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { products } from "@/db/schema";
|
import { products } from "@/db/schema";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type ProductOption = {
|
export type ProductOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,7 +23,8 @@ export type ProductOption = {
|
|||||||
export async function getProductsForSegmentPicker(
|
export async function getProductsForSegmentPicker(
|
||||||
brandId: string
|
brandId: string
|
||||||
): Promise<ProductOption[]> {
|
): Promise<ProductOption[]> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return [];
|
if (!adminUser) return [];
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
|||||||
import { withBrand } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { customers, brandSettings } from "@/db/schema";
|
import { customers, brandSettings } from "@/db/schema";
|
||||||
import type { AudienceRules } from "@/actions/communications/campaigns";
|
import type { AudienceRules } from "@/actions/communications/campaigns";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type SegmentFilterType =
|
export type SegmentFilterType =
|
||||||
| "all_customers"
|
| "all_customers"
|
||||||
@@ -104,7 +105,8 @@ async function saveSegments(brandId: string, segments: Segment[]): Promise<void>
|
|||||||
export async function getHarvestReachSegments(
|
export async function getHarvestReachSegments(
|
||||||
brandId: string
|
brandId: string
|
||||||
): Promise<{ success: true; segments: Segment[] } | { success: false; error: string }> {
|
): Promise<{ success: true; segments: Segment[] } | { success: false; error: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||||
@@ -129,7 +131,8 @@ export async function upsertHarvestReachSegment(params: {
|
|||||||
description?: string;
|
description?: string;
|
||||||
rules: SegmentRuleV2;
|
rules: SegmentRuleV2;
|
||||||
}): Promise<{ success: true; segment: Segment } | { success: false; error: string }> {
|
}): Promise<{ success: true; segment: Segment } | { success: false; error: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
||||||
@@ -178,7 +181,8 @@ export async function deleteHarvestReachSegment(
|
|||||||
segmentId: string,
|
segmentId: string,
|
||||||
brandId: string
|
brandId: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||||
@@ -213,7 +217,8 @@ export async function previewSegmentWithCustomers(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
rules: SegmentRuleV2
|
rules: SegmentRuleV2
|
||||||
): Promise<PreviewResult | null> {
|
): Promise<PreviewResult | null> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return null;
|
if (!adminUser) return null;
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||||
@@ -225,7 +230,9 @@ export async function previewSegmentWithCustomers(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [samples, counts] = await withBrand(brandId, async (db) => {
|
const [samples, counts] = await withBrand(brandId, async (db) => {
|
||||||
const sample = await db
|
// Independent reads — fire in parallel.
|
||||||
|
const [sample, c] = await Promise.all([
|
||||||
|
db
|
||||||
.select({
|
.select({
|
||||||
id: customers.id,
|
id: customers.id,
|
||||||
email: customers.email,
|
email: customers.email,
|
||||||
@@ -234,11 +241,12 @@ export async function previewSegmentWithCustomers(
|
|||||||
})
|
})
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(and(...conds))
|
.where(and(...conds))
|
||||||
.limit(5);
|
.limit(5),
|
||||||
const c = await db
|
db
|
||||||
.select({ value: sql<number>`count(*)::int` })
|
.select({ value: sql<number>`count(*)::int` })
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(and(...conds));
|
.where(and(...conds)),
|
||||||
|
]);
|
||||||
return [sample, c] as const;
|
return [sample, c] as const;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withBrand } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { stops } from "@/db/schema";
|
import { stops } from "@/db/schema";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type StopOption = {
|
export type StopOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,7 +47,8 @@ export async function getStopsForSegmentPicker(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
stopId?: string
|
stopId?: string
|
||||||
): Promise<StopOption[]> {
|
): Promise<StopOption[]> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return [];
|
if (!adminUser) return [];
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
|||||||
import { withTx, pool } from "@/lib/db";
|
import { withTx, pool } from "@/lib/db";
|
||||||
import { orders, orderItems, customers } from "@/db/schema";
|
import { orders, orderItems, customers } from "@/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type ImportOrdersResult =
|
export type ImportOrdersResult =
|
||||||
| { success: true; imported: number; errors: { row: number; error: string }[] }
|
| { success: true; imported: number; errors: { row: number; error: string }[] }
|
||||||
@@ -11,8 +12,9 @@ export type ImportOrdersResult =
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk-import orders. Replaces the legacy `create_order_with_items` SECURITY
|
* Bulk-import orders. Replaces the legacy `create_order_with_items` SECURITY
|
||||||
* DEFINER RPC (`supabase/migrations/021_shipping_only_brand.sql`). The new
|
* DEFINER RPC (originally from the now-archived supabase migrations;
|
||||||
* `orders` schema doesn't have `subtotal`, `customer_name`, `customer_email`,
|
* consolidated into `db/migrations/0001_init.sql`). The new `orders`
|
||||||
|
* schema doesn't have `subtotal`, `customer_name`, `customer_email`,
|
||||||
* `customer_phone`, or `stop_id` columns — totals are stored in
|
* `customer_phone`, or `stop_id` columns — totals are stored in
|
||||||
* `total_cents` and customers are referenced by `customer_id`. For each
|
* `total_cents` and customers are referenced by `customer_id`. For each
|
||||||
* imported order we upsert a `customers` row keyed on email+tenant, then
|
* imported order we upsert a `customers` row keyed on email+tenant, then
|
||||||
@@ -28,7 +30,8 @@ export async function importOrdersBatch(
|
|||||||
items: Array<{ product_id: string; quantity: number; fulfillment: string }>;
|
items: Array<{ product_id: string; quantity: number; fulfillment: string }>;
|
||||||
}>
|
}>
|
||||||
): Promise<ImportOrdersResult> {
|
): Promise<ImportOrdersResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withBrand } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { products } from "@/db/schema";
|
import { products } from "@/db/schema";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type ImportProductsResult =
|
export type ImportProductsResult =
|
||||||
| { success: true; created: number; updated: number; errors: { product: string; error: string }[] }
|
| { success: true; created: number; updated: number; errors: { product: string; error: string }[] }
|
||||||
@@ -27,7 +28,8 @@ export async function importProductsBatch(
|
|||||||
image_url?: string;
|
image_url?: string;
|
||||||
}>
|
}>
|
||||||
): Promise<ImportProductsResult> {
|
): Promise<ImportProductsResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
@@ -38,29 +40,56 @@ export async function importProductsBatch(
|
|||||||
let created = 0;
|
let created = 0;
|
||||||
const errors: { product: string; error: string }[] = [];
|
const errors: { product: string; error: string }[] = [];
|
||||||
|
|
||||||
|
const validProducts: Array<{ name: string; description: string; price: number; type: string; active: boolean; image_url?: string }> = [];
|
||||||
|
const skipped: { product: { name: string; description: string; price: number; type: string; active: boolean; image_url?: string }; error: string }[] = [];
|
||||||
for (const p of productsToImport) {
|
for (const p of productsToImport) {
|
||||||
const priceCents = Math.round(Number(p.price) * 100);
|
const priceCents = Math.round(Number(p.price) * 100);
|
||||||
if (!Number.isFinite(priceCents) || priceCents < 0) {
|
if (!Number.isFinite(priceCents) || priceCents < 0) {
|
||||||
errors.push({ product: p.name, error: "Invalid price" });
|
skipped.push({ product: p, error: "Invalid price" });
|
||||||
continue;
|
} else {
|
||||||
|
validProducts.push(p);
|
||||||
}
|
}
|
||||||
try {
|
}
|
||||||
await withBrand(brandId, (db) =>
|
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
validProducts.map((p) =>
|
||||||
|
withBrand(brandId, (db) =>
|
||||||
db.insert(products).values({
|
db.insert(products).values({
|
||||||
brandId: brandId,
|
brandId: brandId,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
description: p.description ?? null,
|
description: p.description ?? null,
|
||||||
priceCents,
|
priceCents: Math.round(Number(p.price) * 100),
|
||||||
active: p.active,
|
active: p.active,
|
||||||
})
|
}),
|
||||||
|
).then(
|
||||||
|
() => p,
|
||||||
|
(err) => ({ p, err }),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
created++;
|
|
||||||
} catch (err) {
|
for (const entry of settled) {
|
||||||
|
if (entry.status === "rejected") {
|
||||||
|
const err = (entry as PromiseRejectedResult).reason;
|
||||||
errors.push({
|
errors.push({
|
||||||
product: p.name,
|
product: "<unknown>",
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
});
|
});
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
const value = entry.value;
|
||||||
|
if ("err" in value) {
|
||||||
|
errors.push({
|
||||||
|
product: value.p.name,
|
||||||
|
error: value.err instanceof Error ? value.err.message : String(value.err),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of skipped) {
|
||||||
|
errors.push({ product: s.product.name, error: s.error });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, created, updated: 0, errors };
|
return { success: true, created, updated: 0, errors };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
import { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models";
|
import { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -33,7 +34,8 @@ const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1";
|
|||||||
// ── Get AI provider settings ─────────────────────────────────────────────────────
|
// ── Get AI provider settings ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
|
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const res = await pool.query<{
|
const res = await pool.query<{
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
api_key: string | null;
|
api_key: string | null;
|
||||||
@@ -66,7 +68,8 @@ export async function setAIProviderSettings(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
settings: Partial<AIProviderSettings>
|
settings: Partial<AIProviderSettings>
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
@@ -100,7 +103,8 @@ export async function getAIClient(brandId: string): Promise<{
|
|||||||
model: string;
|
model: string;
|
||||||
client: unknown;
|
client: unknown;
|
||||||
} | { provider: "openai"; model: string; client: null; error: string }> {
|
} | { provider: "openai"; model: string; client: null; error: string }> {
|
||||||
const settings = await getAIProviderSettings(brandId);
|
|
||||||
|
await getSession(); const settings = await getAIProviderSettings(brandId);
|
||||||
|
|
||||||
if (!settings.apiKey) {
|
if (!settings.apiKey) {
|
||||||
// Fall back to env var. Check the saved provider first, then universal env keys.
|
// Fall back to env var. Check the saved provider first, then universal env keys.
|
||||||
@@ -192,8 +196,9 @@ export async function getAIClient(brandId: string): Promise<{
|
|||||||
|
|
||||||
// ── Custom integrations ─────────────────────────────────────────────────────────
|
// ── Custom integrations ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> {
|
async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const res = await pool.query<CustomIntegration>(
|
const res = await pool.query<CustomIntegration>(
|
||||||
"SELECT * FROM get_custom_integrations($1)",
|
"SELECT * FROM get_custom_integrations($1)",
|
||||||
[brandId]
|
[brandId]
|
||||||
@@ -204,11 +209,12 @@ export async function getCustomIntegrations(brandId: string): Promise<CustomInte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertCustomIntegration(
|
async function upsertCustomIntegration(
|
||||||
brandId: string,
|
brandId: string,
|
||||||
integration: CustomIntegration
|
integration: CustomIntegration
|
||||||
): Promise<{ success: boolean; integrations?: CustomIntegration[]; error?: string }> {
|
): Promise<{ success: boolean; integrations?: CustomIntegration[]; error?: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
@@ -224,11 +230,12 @@ export async function upsertCustomIntegration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCustomIntegration(
|
async function deleteCustomIntegration(
|
||||||
brandId: string,
|
brandId: string,
|
||||||
integrationId: string
|
integrationId: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -24,7 +25,8 @@ export type SaveCredentialsResult =
|
|||||||
// ── Resend Credentials ─────────────────────────────────────────────────────────
|
// ── Resend Credentials ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getResendCredentials(brandId: string): Promise<ResendCredentials> {
|
export async function getResendCredentials(brandId: string): Promise<ResendCredentials> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const res = await pool.query<{
|
const res = await pool.query<{
|
||||||
api_key: string | null;
|
api_key: string | null;
|
||||||
from_email: string | null;
|
from_email: string | null;
|
||||||
@@ -53,7 +55,8 @@ export async function saveResendCredentials(
|
|||||||
from_name: string | null;
|
from_name: string | null;
|
||||||
}>
|
}>
|
||||||
): Promise<SaveCredentialsResult> {
|
): Promise<SaveCredentialsResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) {
|
if (!adminUser) {
|
||||||
return { success: false, error: "Not authenticated" };
|
return { success: false, error: "Not authenticated" };
|
||||||
}
|
}
|
||||||
@@ -85,7 +88,8 @@ export async function saveResendCredentials(
|
|||||||
// ── Twilio Credentials ─────────────────────────────────────────────────────────
|
// ── Twilio Credentials ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> {
|
export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> {
|
||||||
try {
|
|
||||||
|
await getSession(); try {
|
||||||
const res = await pool.query<{
|
const res = await pool.query<{
|
||||||
account_sid: string | null;
|
account_sid: string | null;
|
||||||
auth_token: string | null;
|
auth_token: string | null;
|
||||||
@@ -114,7 +118,8 @@ export async function saveTwilioCredentials(
|
|||||||
phone_number: string | null;
|
phone_number: string | null;
|
||||||
}>
|
}>
|
||||||
): Promise<SaveCredentialsResult> {
|
): Promise<SaveCredentialsResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) {
|
if (!adminUser) {
|
||||||
return { success: false, error: "Not authenticated" };
|
return { success: false, error: "Not authenticated" };
|
||||||
}
|
}
|
||||||
@@ -149,9 +154,11 @@ export async function testResendConnection(apiKey: string): Promise<{
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}> {
|
}> {
|
||||||
|
|
||||||
if (!apiKey?.trim()) {
|
if (!apiKey?.trim()) {
|
||||||
return { ok: false, message: "API key is required" };
|
return { ok: false, message: "API key is required" };
|
||||||
}
|
}
|
||||||
|
await getSession();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://api.resend.com/domains", {
|
const response = await fetch("https://api.resend.com/domains", {
|
||||||
@@ -177,9 +184,11 @@ export async function testTwilioConnection(
|
|||||||
accountSid: string,
|
accountSid: string,
|
||||||
authToken: string
|
authToken: string
|
||||||
): Promise<{ ok: boolean; message: string }> {
|
): Promise<{ ok: boolean; message: string }> {
|
||||||
|
|
||||||
if (!accountSid?.trim() || !authToken?.trim()) {
|
if (!accountSid?.trim() || !authToken?.trim()) {
|
||||||
return { ok: false, message: "Account SID and Auth Token are required" };
|
return { ok: false, message: "Account SID and Auth Token are required" };
|
||||||
}
|
}
|
||||||
|
await getSession();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const credentials = Buffer.from(`${accountSid}:${authToken}`).toString("base64");
|
const credentials = Buffer.from(`${accountSid}:${authToken}`).toString("base64");
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidateTag } from "next/cache";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
|
||||||
import { pool } from "@/lib/db";
|
|
||||||
|
|
||||||
export type LocationInput = {
|
|
||||||
name: string;
|
|
||||||
address?: string | null;
|
|
||||||
city?: string | null;
|
|
||||||
state?: string | null;
|
|
||||||
zip?: string | null;
|
|
||||||
phone?: string | null;
|
|
||||||
contact_name?: string | null;
|
|
||||||
contact_email?: string | null;
|
|
||||||
notes?: string | null;
|
|
||||||
active?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Location = {
|
|
||||||
id: string;
|
|
||||||
brand_id: string;
|
|
||||||
name: string;
|
|
||||||
address: string | null;
|
|
||||||
city: string | null;
|
|
||||||
state: string | null;
|
|
||||||
zip: string | null;
|
|
||||||
phone: string | null;
|
|
||||||
contact_name: string | null;
|
|
||||||
contact_email: string | null;
|
|
||||||
notes: string | null;
|
|
||||||
active: boolean;
|
|
||||||
deleted_at: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
slug: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LocationWithCount = Location & { stop_count: number };
|
|
||||||
|
|
||||||
// ── Create (single) ──────────────────────────────────────────────────────────
|
|
||||||
export async function createLocation(
|
|
||||||
brandId: string,
|
|
||||||
input: LocationInput
|
|
||||||
): Promise<{ success: true; id: string; slug: string } | { success: false; error: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_stops) {
|
|
||||||
return { success: false, error: "Not authorized to manage locations" };
|
|
||||||
}
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
|
||||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
|
||||||
return { success: false, error: "Brand access required" };
|
|
||||||
}
|
|
||||||
const effectiveBrandId = activeBrandId;
|
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand selected" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ id: string; slug: string }>(
|
|
||||||
`SELECT * FROM admin_create_location(
|
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
|
||||||
)`,
|
|
||||||
[
|
|
||||||
effectiveBrandId,
|
|
||||||
input.name,
|
|
||||||
input.address ?? null,
|
|
||||||
input.city ?? null,
|
|
||||||
input.state ?? null,
|
|
||||||
input.zip ?? null,
|
|
||||||
input.phone ?? null,
|
|
||||||
input.contact_name ?? null,
|
|
||||||
input.contact_email ?? null,
|
|
||||||
input.notes ?? null,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const data = rows[0];
|
|
||||||
if (!data) {
|
|
||||||
return { success: false, error: "Insert failed" };
|
|
||||||
}
|
|
||||||
revalidateTag("locations", "default");
|
|
||||||
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
|
||||||
return { success: true, id: data.id, slug: data.slug };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : "Insert failed",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create (batch) ───────────────────────────────────────────────────────────
|
|
||||||
export async function createLocationsBatch(
|
|
||||||
brandId: string,
|
|
||||||
locations: LocationInput[]
|
|
||||||
): Promise<{ success: boolean; created: number; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, created: 0, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_stops) {
|
|
||||||
return { success: false, created: 0, error: "Not authorized" };
|
|
||||||
}
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
|
||||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
|
||||||
return { success: false, created: 0, error: "Brand access required" };
|
|
||||||
}
|
|
||||||
const effectiveBrandId = activeBrandId;
|
|
||||||
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
|
|
||||||
|
|
||||||
let inserted: { id?: string }[] = [];
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ id?: string }>(
|
|
||||||
"SELECT * FROM admin_create_locations_batch($1, $2::jsonb)",
|
|
||||||
[effectiveBrandId, JSON.stringify(locations)],
|
|
||||||
);
|
|
||||||
inserted = rows;
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
created: 0,
|
|
||||||
error: err instanceof Error ? err.message : "Insert failed",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidateTag("locations", "default");
|
|
||||||
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
created: Array.isArray(inserted) ? inserted.length : locations.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Update (partial) ─────────────────────────────────────────────────────────
|
|
||||||
export async function updateLocation(
|
|
||||||
locationId: string,
|
|
||||||
brandId: string,
|
|
||||||
updates: Partial<LocationInput>
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
let data: { success?: boolean; error?: string } = {};
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
|
|
||||||
"SELECT * FROM admin_update_location($1, $2, $3::jsonb)",
|
|
||||||
[locationId, brandId, JSON.stringify(updates)],
|
|
||||||
);
|
|
||||||
data = rows[0] ?? {};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : "Update failed",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
return { success: false, error: data.error ?? "Update failed" };
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidateTag("locations", "default");
|
|
||||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete (soft) ────────────────────────────────────────────────────────────
|
|
||||||
export async function deleteLocation(
|
|
||||||
locationId: string,
|
|
||||||
brandId: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
let data: { success?: boolean; error?: string } = {};
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
|
|
||||||
"SELECT * FROM admin_delete_location($1, $2)",
|
|
||||||
[locationId, brandId],
|
|
||||||
);
|
|
||||||
data = rows[0] ?? {};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : "Delete failed",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
return { success: false, error: data.error ?? "Delete failed" };
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidateTag("locations", "default");
|
|
||||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Read (admin, by brand_id) ────────────────────────────────────────────────
|
|
||||||
export async function adminListLocations(
|
|
||||||
brandId: string
|
|
||||||
): Promise<LocationWithCount[]> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return [];
|
|
||||||
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
|
||||||
const effectiveBrandId = activeBrandId;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<LocationWithCount>(
|
|
||||||
"SELECT * FROM admin_list_locations($1)",
|
|
||||||
[effectiveBrandId],
|
|
||||||
);
|
|
||||||
return Array.isArray(rows) ? rows : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Read (public, by brand slug) ─────────────────────────────────────────────
|
|
||||||
export type PublicLocation = Pick<
|
|
||||||
Location,
|
|
||||||
"id" | "name" | "address" | "city" | "state" | "zip" | "phone" | "slug"
|
|
||||||
> & { stop_count: number };
|
|
||||||
|
|
||||||
export async function getPublicLocationsForBrand(
|
|
||||||
brandSlug: string
|
|
||||||
): Promise<PublicLocation[]> {
|
|
||||||
if (!brandSlug) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<PublicLocation>(
|
|
||||||
"SELECT * FROM get_locations_for_brand($1)",
|
|
||||||
[brandSlug],
|
|
||||||
);
|
|
||||||
return Array.isArray(rows) ? rows : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Attach a stop to a location ──────────────────────────────────────────────
|
|
||||||
export async function attachStopToLocation(
|
|
||||||
stopId: string,
|
|
||||||
locationId: string,
|
|
||||||
brandId: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
let data: { success?: boolean; error?: string } = {};
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
|
|
||||||
"SELECT * FROM admin_attach_location_to_stop($1, $2, $3)",
|
|
||||||
[stopId, locationId, brandId],
|
|
||||||
);
|
|
||||||
data = rows[0] ?? {};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : "Attach failed",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
return { success: false, error: data.error ?? "Attach failed" };
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidateTag("stops", "default");
|
|
||||||
revalidateTag("locations", "default");
|
|
||||||
revalidateTag(`brand:${brandId}:stops`, "default");
|
|
||||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offline mutation dispatcher.
|
* Offline mutation dispatcher.
|
||||||
@@ -45,13 +46,32 @@ interface ClientAction {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listClientActions(): Promise<ClientAction[]> {
|
async function listClientActions(): Promise<ClientAction[]> {
|
||||||
|
|
||||||
|
await getSession();
|
||||||
// Wire real server actions here as they land:
|
// Wire real server actions here as they land:
|
||||||
// { name: "markOrderReady", handler: markOrderReady },
|
// { name: "markOrderReady", handler: markOrderReady },
|
||||||
// { name: "markOrderPickedUp", handler: markOrderPickedUp },
|
// { name: "markOrderPickedUp", handler: markOrderPickedUp },
|
||||||
// { name: "updateStopStatus", handler: updateStopStatus },
|
// { name: "updateStopStatus", handler: updateStopStatus },
|
||||||
// { name: "adjustProductStock", handler: adjustProductStock },
|
// { name: "adjustProductStock", handler: adjustProductStock },
|
||||||
return [];
|
//
|
||||||
|
// Time Tracking (cycle 12) — replay handlers for offline clock
|
||||||
|
// events. The replay re-verifies the worker's PIN before invoking
|
||||||
|
// the field action, then the action writes with submitted_via
|
||||||
|
// distinguishable so payroll can audit.
|
||||||
|
const { replayOfflineClock } = await import("./time-tracking/offline-handlers");
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "timeTracking.clockIn",
|
||||||
|
handler: async (payload: unknown, _clientActionId: string) =>
|
||||||
|
replayOfflineClock(payload as never),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "timeTracking.clockOut",
|
||||||
|
handler: async (payload: unknown, _clientActionId: string) =>
|
||||||
|
replayOfflineClock(payload as never),
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dispatchClientAction(
|
export async function dispatchClientAction(
|
||||||
@@ -63,7 +83,8 @@ export async function dispatchClientAction(
|
|||||||
| { ok: false; conflict: string }
|
| { ok: false; conflict: string }
|
||||||
| { ok: false; error: string }
|
| { ok: false; error: string }
|
||||||
> {
|
> {
|
||||||
const actions = await listClientActions();
|
|
||||||
|
await getSession(); const actions = await listClientActions();
|
||||||
const action = actions.find((a) => a.name === actionName);
|
const action = actions.find((a) => a.name === actionName);
|
||||||
if (!action) {
|
if (!action) {
|
||||||
return { ok: false, error: "Unknown action" };
|
return { ok: false, error: "Unknown action" };
|
||||||
|
|||||||
+17
-10
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type AdminOrder = {
|
export type AdminOrder = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -106,7 +107,8 @@ type AdminOrderDetail = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getAdminOrders(): Promise<AdminOrdersResponse> {
|
export async function getAdminOrders(): Promise<AdminOrdersResponse> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
const brandId = adminUser?.brand_id ?? null;
|
const brandId = adminUser?.brand_id ?? null;
|
||||||
|
|
||||||
// The legacy `get_admin_orders` RPC is a SECURITY DEFINER function that
|
// The legacy `get_admin_orders` RPC is a SECURITY DEFINER function that
|
||||||
@@ -134,26 +136,30 @@ export async function getAdminOrders(): Promise<AdminOrdersResponse> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminStops(): Promise<AdminStop[]> {
|
async function getAdminStops(): Promise<AdminStop[]> {
|
||||||
const result = await getAdminOrders();
|
|
||||||
|
await getSession(); const result = await getAdminOrders();
|
||||||
if (!result.success) return [];
|
if (!result.success) return [];
|
||||||
return result.stops;
|
return result.stops;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminPendingOrders(): Promise<AdminOrder[]> {
|
async function getAdminPendingOrders(): Promise<AdminOrder[]> {
|
||||||
const result = await getAdminOrders();
|
|
||||||
|
await getSession(); const result = await getAdminOrders();
|
||||||
if (!result.success) return [];
|
if (!result.success) return [];
|
||||||
return result.orders.filter((o) => !o.pickup_complete);
|
return result.orders.filter((o) => !o.pickup_complete);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminPickedUpOrders(): Promise<AdminOrder[]> {
|
async function getAdminPickedUpOrders(): Promise<AdminOrder[]> {
|
||||||
const result = await getAdminOrders();
|
|
||||||
|
await getSession(); const result = await getAdminOrders();
|
||||||
if (!result.success) return [];
|
if (!result.success) return [];
|
||||||
return result.orders.filter((o) => o.pickup_complete);
|
return result.orders.filter((o) => o.pickup_complete);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDetail | null> {
|
export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDetail | null> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
const brandId = adminUser?.brand_id ?? null;
|
const brandId = adminUser?.brand_id ?? null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -175,11 +181,12 @@ export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDe
|
|||||||
* When the pickup flow is rebuilt against the SaaS schema, this should
|
* When the pickup flow is rebuilt against the SaaS schema, this should
|
||||||
* be replaced by a Drizzle update on a `pickup_events` table.
|
* be replaced by a Drizzle update on a `pickup_events` table.
|
||||||
*/
|
*/
|
||||||
export async function toggleOrderPickupComplete(params: {
|
async function toggleOrderPickupComplete(params: {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
pickupComplete: boolean;
|
pickupComplete: boolean;
|
||||||
}): Promise<{ success: true } | { success: false; error: string }> {
|
}): Promise<{ success: true } | { success: false; error: string }> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
|||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export type AdminCreateOrderItem = {
|
export type AdminCreateOrderItem = {
|
||||||
product_id: string;
|
product_id: string;
|
||||||
@@ -39,7 +40,8 @@ export async function createAdminOrder(
|
|||||||
brandId: string | null,
|
brandId: string | null,
|
||||||
input: AdminCreateOrderInput
|
input: AdminCreateOrderInput
|
||||||
): Promise<AdminCreateOrderResult> {
|
): Promise<AdminCreateOrderResult> {
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
await getSession(); const adminUser = await getAdminUser();
|
||||||
if (!adminUser) {
|
if (!adminUser) {
|
||||||
return { success: false, error: "Not authenticated" };
|
return { success: false, error: "Not authenticated" };
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user