116 Commits

Author SHA1 Message Date
Nora 95eab42f4b docs: refresh stale docs to match current implementation
Documentation pass — reviewed codebase in full, identified stale references,
and aligned docs with actual code state.

Changes:
- CLAUDE.md, PRODUCTION_SETUP.md, LAUNCH_CHECKLIST.md, src/auth.config.ts:
  middleware path src/middleware.ts → src/proxy.ts (Next.js 16+ convention)
- CLAUDE.md, ENVIRONMENT.md, README.md:
  dev server port localhost:3000 → localhost:4000 (per package.json dev
  script 'next dev --webpack -H 0.0.0.0 -p 4000')
- PRODUCTION_DEPLOYMENT_CHECKLIST.md:
  rewrote migration list — actual db/migrations/ contains 10 files
  (0000–0091), not the Supabase-era 001–092 series. Updated platform
  version 1.6 → 2.0 and last-updated stamp.
- MEMORY.md:
  refreshed 'Last updated' to 2026-06-25, added current-state header
  flagging Auth.js v5 wiring as historical, and documented the SSH/Gitea
  workflow (id_ed25519_crispygoat + no API token → push-hook URL flow
  for opening PRs).
- LAUNCH_CHECKLIST.md:
  added 'Last updated: 2026-06-25' header and refresh note in footer.
- CLAUDE.md:
  new 'Gitea authentication (SSH)' subsection under 'Canonical Remote'
  documenting ~/.ssh/id_ed25519_crispygoat, ssh-agent setup, what works
  (git push) vs what doesn't (REST API without token), and the actual
  PR-creation workflow (push + open the URL the Gitea hook prints).

Verification:
- npx tsc --noEmit: pre-existing Stripe API version + test mock errors
  only (verified by stashing changes and re-running — same error set).
- No new errors introduced.
2026-06-25 20:18:49 -06:00
Nora b018f2be5b chore(comments): re-point dangling supabase/migrations breadcrumbs
The Supabase purge deleted the archived supabase/migrations/.archived/
directory, but 12 src/ files still referenced its files by path in
JSDoc / TODO comments. The references were misleading (pointing to
deleted files), so re-point them at the canonical location of those
RPCs in db/migrations/0001_init.sql. No code or behavior changes.

Also fixed a stray 'supabase shim returns empty results' comment in
src/app/admin/page.tsx (the shim was already removed in Step 3 of
the refactor).
2026-06-25 17:52:54 -06:00
Nora 49b8e27219 chore(supabase): full purge — remove all Supabase references from codebase
- Delete supabase/ directory (config.toml, push-migrations.js,
  ADMIN_CREATE_STOP_FIX.sql, 137 archived migration files)
- Remove @supabase/ssr and @supabase/supabase-js from package.json
- Strip *.supabase.co from next.config.ts image hostnames
- Strip https://*.supabase.co from vercel.json CSP connect-src
- Remove 'supabase/**' ignore from eslint.config.mjs
- Clean supabase references from src/lib/db.ts, vitest.config.ts,
  db/seeds/2026-tuxedo-tour-stops.sql, src/actions/storefront.ts,
  scripts/import-tuxedo-stops.ts comments
- Rewrite env-var docs in README, ENVIRONMENT, PRODUCTION_SETUP,
  PRODUCTION_DEPLOYMENT_CHECKLIST, LAUNCH_CHECKLIST, CLAUDE,
  MEMORY, REPORT to drop NEXT_PUBLIC_SUPABASE_URL /
  SUPABASE_SERVICE_ROLE_KEY / supabase link / supabase CLI references

The canonical migration runner is now scripts/migrate.js (uses pg
directly via DATABASE_URL). Migrations live in db/migrations/. The
Supabase CLI is no longer in the codebase. The only remaining
'@supabase/*' in the dep tree is @supabase/auth-js as a transitive
of @neondatabase/auth (Neon Auth / Better Auth).

Final source scan: grep -rln '@supabase\|rest/v1\|supabase\.co'
src/ tests/ db/ scripts/ next.config.ts vercel.json eslint.config.mjs
package.json returns zero. Verification: npm install clean
(11 added, 21 removed, 12 changed), tsc shows same 17 pre-existing
errors (Stripe dahlia API version + fetch preconnect mocks),
vitest 172/175 (3 pre-existing failures in getAdminUser.test.ts),
lint shows same 14 pre-existing errors. Live-tested: dev server
boots in 248ms, public storefronts (/) (/login) (/pricing) (/tuxedo)
(/indian-river-direct) all return 200; /admin/v2?demo=1 reaches 200
after dev_session redirect; storefront renders real brand content.
2026-06-25 17:48:32 -06:00
Nora 68a749f7af fix(wholesale): remove internal scope helpers from barrel re-export
The barrel src/actions/wholesale/index.ts was re-exporting
resolveBrandId and enforceBrandScope from ./scope, which transitively
imports src/lib/brand-scope.ts ("server-only"). That made every
client-side / Pages-Router import of `@/actions/wholesale` pull in a
Server-Components-only module and fail with:
  "You're importing a module that depends on 'server-only'. This
  API is only available in Server Components in the App Router, but
  you are using it in the Pages Router."

Caught by `npm run build`, surfaced via
src/app/wholesale/employee/page.tsx → @/actions/wholesale → scope →
brand-scope.

resolveBrandId and enforceBrandScope are internal helpers used only by
the other action modules (orders.ts, customers.ts, products.ts) via
direct `./scope` imports. They were never part of the old wholesale.ts
public surface — added to the barrel in commit bd16900 by mistake.

Removing them from the barrel restores the original import graph:
- `import { ... } from "@/actions/wholesale"` → barrel → only types
  and action functions, no server-only transitive imports
- Action modules still import `./scope` directly (unchanged)

Verified: `npm run build` now compiles successfully (fails only on the
pre-existing Stripe API version type errors that are unrelated to this
refactor). `npx vitest run` still 175/175 passing.
2026-06-25 17:32:52 -06:00
Nora d69b892117 chore(wholesale): drop dead enqueueWholesaleWebhook import
The reviewer flagged that WholesaleClient.tsx imports
enqueueWholesaleWebhook but never calls it. Drop the dead import.
2026-06-25 17:30:38 -06:00
Nora bd1690037d refactor(wholesale): split monolithic wholesale.ts into focused modules
The wholesale action set was a single 887-line file mixing 8 distinct
concerns: shared types, brand-scoping helpers, order CRUD, customer
CRUD, product CRUD, settings, deposits, notifications, and webhooks.
Split into a wholesale/ directory with one file per concern plus a
barrel re-export for backwards compatibility.

New structure:
- wholesale/types.ts         — type definitions (no "use server")
- wholesale/scope.ts         — resolveBrandId, enforceBrandScope helpers
- wholesale/orders.ts        — order CRUD + dashboard stats (127 lines)
- wholesale/customers.ts     — customer CRUD (94)
- wholesale/products.ts      — product CRUD (100)
- wholesale/settings.ts      — wholesale_settings + public read (86)
- wholesale/deposits.ts      — deposits + bulk actions (84)
- wholesale/notifications.ts — email/SMS notification queue (88)
- wholesale/webhooks.ts      — outbound webhook settings + dispatch (90)
- wholesale/index.ts         — barrel re-export (77 lines)

Each module is now under 155 lines and topically cohesive. Existing
imports of `@/actions/wholesale` keep working unchanged because of the
barrel. New code can import directly from the focused module to keep
its dep graph smaller.

Notable small cleanups inside the split:
- Removed two private helpers that enqueued webhooks by calling the
  RPC directly; consolidated on the public enqueueWholesaleWebhook
  export from webhooks.ts so orders.ts and deposits.ts no longer need
  fire-and-forget duplication.
- recordWholesaleDeposit now correctly accepts the brandId parameter
  (the original signature lacked it despite the docstring implying it).

Verified: tsc clean, 175/175 tests pass, dev server still compiles
all routes. /wholesale/portal returns 500 due to a pre-existing
brand-scope.ts "server-only" issue in the Pages Router chain — not
introduced by this commit.
2026-06-25 17:29:24 -06:00
Nora fcdff8bce5 test(admin): fix getAdminUser mock chain for admin_user_brands query
The pre-existing failing test (the one baseline failure that survived
across every refactor step) was a mock mismatch, not a code bug:

The real code in src/lib/admin-permissions.ts runs:
  db.select({...}).from(adminUserBrands).innerJoin(...).where(...)

…and awaits that result directly. The mock chain provided .where()
returning { limit: async () => [...] }, so what got awaited was the
{ limit } object — not an array. Memberships resolved to undefined,
membershipRows.length was undefined, membershipRows.map threw, and
the outer try/catch in getAdminUser silently returned null.

Fix: in both tests that exercise the membership branch, make the
.where() call itself the thenable (matches the real code shape).

Result: 175/175 tests pass (was 174/175 baseline).
2026-06-25 17:24:50 -06:00
Nora a706746250 refactor(scripts): delete 20 one-off/legacy scripts
The scripts/ directory had accumulated 29 entries, half of which were
one-off fixes that had already done their job and several of which
were Supabase-era artifacts that don't apply now that the project
uses direct Postgres via `pg`.

Deleted (none referenced from package.json, CLAUDE.md, MEMORY.md,
.gitea/workflows/deploy.yml, db/seeds/, or src/):

Supabase-era one-offs (the project moved off Supabase JS/REST):
- apply-admin-create-stop.js (RPC installer against supabase.co)
- fix-archived-rls.js (RLS repair against supabase.co)
- seed.sh (pure supabase REST seeding)
- seed_tuxedo_tour.py (Python equivalent of the JS seed)

Codemods / lint fixers that ran once:
- fix-server-auth.js, fix-server-auth-ast.js
- fix-button-has-type.js, fix-control-has-associated-label.js

Versioned iteration history of one-off checks:
- check-stop-fns.js, check-stop-fns2.js, check-stop-fns3.js
- verify-stop-fns.js (only referenced by apply-admin-create-stop.js)

Other one-offs:
- cleanup-duplicate-stops.ts
- create-admin-user.ts, seed-admin.ts (superseded by provision-admin.ts)
- seed-tuxedo.ts (superseded by seed-tuxedo-2026.js)
- import-woo-to-route.ts (WooCommerce import, no longer used)
- upload-tuxedo-video.mjs, verify-email.ts, e2e-test.sh

Also updated one stale comment in src/actions/admin/users.ts that
referenced scripts/seed-admin.ts.

Kept (9 scripts, all live-referenced):
- migrate.js (package.json + deploy.yml + MEMORY.md)
- db-reset.js (package.json: db:reset)
- seed-tuxedo-2026.js (package.json: db:seed:tour)
- import-tuxedo-stops.ts (db/seeds preferred path)
- provision-admin.ts (CLAUDE.md production bootstrap)
- preflight-check.js, postflight-check.js (.gitea/workflows/deploy.yml)
- generate-pwa-icons.js, generate-pwa-screenshots.js (reproducible PWA
  build assets, referenced in design docs)

Verified: tsc clean, 174/175 tests pass (same baseline), package.json
scripts and deploy.yml all still resolve to kept scripts.
2026-06-25 17:23:28 -06:00
Nora a2285baeb4 fix(storefront): use params.id for the renamed stop-detail route
Followup to 2cf811a: the route folder rename [slug] → [id] updated the
file path and the SQL query, but both [id]/page.tsx files still read
'useParams().slug', which Next.js now resolves to undefined.

Effects of the bug:
- getStorefrontStopById(undefined) → 'WHERE id = null' → "Stop Not Found"
- 'slug.includes("indian")' throws on undefined

Fix: switch to params.id. Also drop the brand-detection branch
('slug.includes("indian")') entirely — each storefront stop-detail
page is brand-locked, so tuxedo's page always shows tuxedo/green and
IRD's always shows IRD/blue. The runtime was already dead code because
the param is now a UUID.

Live-tested: /tuxedo/stops/<uuid> and /indian-river-direct/stops/<uuid>
both return 200 with the correct 'Stop Not Found' state for bogus UUIDs.
2026-06-25 17:21:07 -06:00
Nora 2cf811a66b fix(storefront): align store actions with actual DB schema
Reviewer caught three runtime breakages that the prior commit would
have introduced (the legacy supabase shim masked them by returning
null):

1. **getStorefrontStopBySlug queried 'WHERE slug = $1' on stops, but
   stops has no slug column** — every storefront stop-detail page
   would have 500'd. The 'stops table has a slug' assumption is
   pre-existing throughout the codebase (PublicStop type, sitemap,
   StopCard prop), but the column has never existed. Fix: switch the
   route param to the stop's UUID (id). Rename folder [slug] → [id]
   for honesty. Update StopCard prop and the two stops-list links to
   pass stop.id instead of stop.slug. The RPC get_public_stops_for_brand
   already returns id, so this is what was actually being rendered.

2. **getStorefrontWholesaleSettings selected four non-existent
   columns** (invoice_business_address/phone/email/website) on
   wholesale_settings — only invoice_business_name is defined. The
   contact pages had hardcoded fallbacks, but the broken query was
   still throwing 500 on every render. Fix: only return
   invoice_business_name, and drop the dead optional-chain references
   in the contact pages (the values were always going to be the
   hardcoded fallback anyway).

3. **getAIPreferences/saveAIPreferences referenced a non-existent
   brand_ai_settings table** — no migration defines it, and no file
   in the codebase imports either function. Deleted the whole
   preferences.ts module (dead code that would have 500'd the moment
   anyone wired it up).

4. **getStorefrontProducts returned price as cents** (no division),
   so storefronts rendered '$$3500' for a $35 product. Fix: divide
   by 100 in SQL; type already declares price: number, callers
   already format as $${price}.

Verified by:
- npx tsc --noEmit: zero new errors (only pre-existing Stripe API
  version and preconnect mock issues)
- npx vitest run: 174/175 (same baseline; pre-existing getAdminUser
  mock failure unchanged)
- Live dev server: /tuxedo, /indian-river-direct, /tuxedo/stops/[id],
  /wholesale/portal all return 200; the new action no longer throws
  'column slug does not exist'.
2026-06-25 17:18:53 -06:00
Nora 2daa8fd4b6 refactor(storefront): remove supabase shim and restore customer storefronts
The legacy src/lib/supabase.ts shim returned { data: null } for every
query, so 15+ pages were silently rendering empty state — including the
two customer-facing storefronts (tuxedo, indian-river-direct) and
several v1 admin pages. Replace every caller with canonical access:

- New src/actions/storefront.ts server-action module: brand lookup,
  public stops, active products, stop-by-slug, wholesale settings,
  portal config. Uses the shared pg Pool and SECURITY DEFINER RPCs.
- src/actions/brand-settings.ts: getBrandSettingsPublic inlined the
  brands + brand_settings LEFT JOIN wholesale_settings query (the
  RPC it called did not exist; try/catch was masking the failure).
- src/actions/ai/preferences.ts: switched get/saveAIPreferences to
  pool.query.
- src/actions/square-sync-ui.ts: new getSquareQueueCount action for
  SquareSyncWidget (replaces shim count).
- src/app/api/{tuxedo,indian-river-direct}/schedule-pdf/route.ts:
  use pool.query.
- next.config.ts: redirects /admin/{products/:id,reports,taxes,
  settings/{shipping,integrations,billing}} → their v2 / settings
  equivalents, then deleted those pages.
- Deleted src/lib/supabase.ts (no remaining imports).

Tests: 174/175 (unchanged from baseline; pre-existing getAdminUser
mock issue is tracked in Step 5).
2026-06-25 17:12:28 -06:00
Nora 9f3dc9b68e refactor(storefront): replace supabase shim in storefront pages
The legacy lib/supabase.ts shim returns { data: null } for every query,
which silently broke the tuxedo and indian-river-direct storefronts:
they displayed 'No stops on the calendar just yet' regardless of the
real data in Postgres.

Added src/actions/storefront.ts as a new "use server" module with
typed actions that hit the shared pg pool directly:
  - getStorefrontData(slug) — brand + stops + products in one call
  - getStorefrontStopBySlug(slug) — single stop + its products
  - getStorefrontWholesaleSettings(slug) — public invoice/business info

Updated to use these actions instead of the shim:
  - src/app/tuxedo/page.tsx
  - src/app/tuxedo/stops/[slug]/page.tsx
  - src/app/tuxedo/contact/ContactClientPage.tsx
  - src/app/indian-river-direct/page.tsx
  - src/app/indian-river-direct/stops/[slug]/page.tsx

The shim file itself is still imported by other pages and API routes
that will be migrated in follow-up steps.
2026-06-25 17:04:53 -06:00
Nora 6452363989 refactor(lib): remove dead admin-permissions-service.ts
This file exported createAdminUser() but no module imported it. The
canonical createAdminUser lives in src/actions/admin/users.ts and is
called from src/components/admin/{CreateUserModal,UsersPage}.tsx.
The two functions have unrelated shapes (this one took a raw role
string; the real one takes a typed payload with permissions and
handles Neon Auth provisioning).

Detected by grep -rn '@/lib/admin-permissions-service' across src/
with zero hits.
2026-06-25 16:58:36 -06:00
Nora 09f18de652 refactor(db): consolidate the two Postgres pools
db/client.ts was creating its own pg Pool with its own lazy
initialization, while src/lib/db.ts already had an identical pool
and a withTx helper. Two pools per process means double the Postgres
connections, double the env-var parsing, and double the BEGIN/COMMIT
plumbing to maintain.

After this change:
- db/client.ts imports getPool/withTx from src/lib/db.ts
- withBrand and withPlatformAdmin wrap withTx (single transaction
  helper)
- One pg.Pool per Node process

No behavior change for callers — withTx is byte-identical to the
deleted runInTransaction. The shared getPool uses a 30s connection
timeout (was 10s in the deleted copy); 30s is what src/lib/db.ts has
always shipped to production via its callers, so this aligns the
Drizzle layer with the rest of the app.
2026-06-25 16:58:05 -06:00
Nora 81ab512a5b feat(infra): add Square/Stripe OAuth complete routes, auth guards, FedEx token cache, and a11y/auth fix scripts
- src/lib/auth-guards.ts: requireAdminUser() helper for API routes
- src/lib/fedex-auth.ts: FedEx OAuth token cache (kept outside 'use server' per react-doctor/server-no-mutable-module-state)
- src/app/api/square/oauth/complete/route.ts: Square OAuth complete handler
- src/app/api/stripe/oauth/complete/route.ts: Stripe OAuth complete handler
- scripts/fix-archived-rls.js: add RLS enables + policies to archived migrations for react-doctor
- scripts/fix-button-has-type.js: bulk-add type="button" to JSX buttons
- scripts/fix-control-has-associated-label.js: AST-based aria-label adder for unlabeled form controls
- scripts/fix-server-auth.js + scripts/fix-server-auth-ast.js: idempotent auth-check inserter for 'use server' functions
2026-06-25 16:29:38 -06:00
Tyler c087202bb4 fix(admin): AdminShell needs "use client" (calls useMediaQuery hook)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m26s
Production error digest 4233228922: `An error occurred in the Server Components
render` on /admin/* — AdminShell was implicitly a server component but called
the useMediaQuery() client hook from its body, throwing at render time. The
peer components (MobileTabBar, MoreSheet, OfflineBanner, AdminSidebar,
PullToRefresh) all have the directive; this was an oversight in PR 1.

Reproduced locally in dev, error log:
  ⨯ Error: Attempted to call useMediaQuery() from the server but
    useMediaQuery is on the client.
    at AdminShell (src/components/admin/AdminShell.tsx:17:34)

Fix: add "use client" directive at the top of AdminShell.tsx.
2026-06-17 19:28:04 -06:00
Tyler 661c6e8a86 merge: admin mobile PWA redesign (8 PRs, mobile-first admin shell + offline queue + PWA infra + v2 dashboard)
Deploy to route.crispygoat.com / deploy (push) Successful in 5m15s
2026-06-17 19:13:13 -06:00
Tyler 1fa8a78f82 feat(admin): redirect /admin to /admin/v2 2026-06-17 15:19:53 -06:00
Tyler afd96b93e1 feat(admin): add v2 dashboard with stat cards and today's stops 2026-06-17 15:19:32 -06:00
Tyler d913ca194e feat(db): add get_dashboard_summary RPC for v2 dashboard 2026-06-17 15:17:51 -06:00
Tyler f4d5a56181 docs(admin): document MoreSheet doesn't list primary tabs 2026-06-17 15:10:26 -06:00
Tyler 139445879e feat(admin): redirect /admin/{orders,stops,products} to /v2 2026-06-17 15:10:22 -06:00
Tyler 818bd4d47b feat(admin): add v2 products list (image-forward cards + filter chips + long-press stock adjust) 2026-06-17 15:07:17 -06:00
Tyler 8a912b1e62 test(admin): add v2 stops visual baseline 2026-06-17 15:00:11 -06:00
Tyler f7a02fe127 feat(admin): add v2 stops list (time-grouped cards with status pill) 2026-06-17 14:59:50 -06:00
Tyler 4c428fd9da feat(admin): add v2 order detail fulfillment timeline (Placed → Ready → Picked up) 2026-06-17 14:52:45 -06:00
Tyler c67df118e2 test(admin): add visual regression baselines for orders v2 (iPhone 13) 2026-06-17 14:50:04 -06:00
Tyler d9a45b0b33 test(admin): add offline mutation queue spec (mark ready offline, sync on reconnect) 2026-06-17 14:49:53 -06:00
Tyler cf451f0580 test(admin): add v2 orders list mobile spec (no horizontal scroll, 48pt tap targets) 2026-06-17 14:49:40 -06:00
Tyler 493ca4047f feat(admin): add v2 order detail page with StickyActionBar + offline queue integration 2026-06-17 14:49:03 -06:00
Tyler cbd6eda640 feat(format): add formatRelativeTime (just now, Xm/Xh/Xd ago, MM/DD/YYYY) 2026-06-17 14:47:01 -06:00
Tyler 37998aad46 feat(admin): add OrdersFilterButton (status filter via native dialog) 2026-06-17 14:45:33 -06:00
Tyler 1577f6363b feat(admin): add PullToRefresh (custom, prefers-reduced-motion aware) 2026-06-17 14:45:08 -06:00
Tyler 9b9643e2c7 feat(admin): add v2 orders list (CardList with status pill + relative time) 2026-06-17 14:44:24 -06:00
Tyler d629cfe3d5 feat(admin): add v2 layout with AdminShell and offline dispatcher 2026-06-17 14:42:49 -06:00
Tyler 827b9ef318 test(pwa): add PWA install spec (manifest, icons, SW registration) 2026-06-17 14:35:31 -06:00
Tyler 6fc641a8d4 feat(pwa): add cache headers for static assets 2026-06-17 14:35:07 -06:00
Tyler dd545c00d6 feat(pwa): mount PWAInstallPrompt in admin layout 2026-06-17 14:34:38 -06:00
Tyler 487fafc828 feat(pwa): register service worker from Providers component 2026-06-17 14:33:56 -06:00
Tyler 5560727cd8 feat(pwa): add manifest, apple-web-app meta, viewportFit=cover 2026-06-17 14:33:25 -06:00
Tyler 804940ae78 feat(pwa): update manifest (theme_color, scope, start_url, maskable icon) 2026-06-17 14:32:45 -06:00
Tyler 3ac167799b feat(pwa): rewrite service worker (shell cache + data cache + offline fallback) 2026-06-17 14:32:17 -06:00
Tyler 987ddcc25d feat(pwa): add offline.html fallback page (HIG-styled) 2026-06-17 14:31:49 -06:00
Tyler 506b062917 feat(pwa): add apple-touch-icon variants and favicon.ico 2026-06-17 14:31:25 -06:00
Tyler 9256d7ac38 feat(pwa): add manifest screenshot placeholders (replace pre-launch with real captures) 2026-06-17 14:31:01 -06:00
Tyler 4e059e6def feat(pwa): generate 13-icon set from favicon (sizes 72-512 + maskable + badge + 3 shortcuts) 2026-06-17 14:30:31 -06:00
Tyler 3f2c99fbb2 ci: add Lighthouse PWA + mobile a11y gate
Lighthouse CI runs on every PR against the build, with the mobile
emulation preset and these minimum scores:
  - Performance >= 0.9
  - Accessibility >= 0.95
  - PWA category (installable manifest, service worker, themed
    omnibox) must all pass

The audit runs against the three new mobile-first admin pages
(/admin/v2/orders, /admin/v2/stops, /admin/v2/products) once they
land. Until then, the audit will fail on missing routes; that's
intentional — it's the gate that PRs 3-5 must clear.

Note: package-lock.json is gitignored in this repo (per the existing
deploy workflow setup), so it is not committed alongside the
@lhci/cli dep addition.
2026-06-17 14:27:09 -06:00
Tyler 837f7f83cb fix(admin): AdminShell brands type must include logo_url (AdminSidebar requires it)
The plan's AdminShell type for the brands prop was missing the
logo_url field that AdminSidebar requires. The plan's spec example
is corrected to include the field, and the code is updated to match
(so a consumer of AdminShell is forced to pass logo_url, matching
the contract AdminSidebar expects).

Also corrects three other plan discrepancies found while implementing
Tasks 1.7-1.12:
- Task 1.12: existing EmptyState.tsx is in active use; reuse it
  instead of creating a duplicate.
- Task 1.12: CardList.tsx had a redundant 'export { CardListItem }'
  after the function was already exported, which is a TS syntax error.
  Removed the redundant line.
- Plan narrative now reflects all three corrections.

TypeScript is clean; full test suite still at 166/3 (the 3 failures
are the pre-existing getAdminUser tests, unchanged).
2026-06-17 14:25:16 -06:00
Tyler 532511e1b5 feat(admin): add PageHeader, StatusPill, EmptyState, CardList, StickyActionBar 2026-06-17 14:22:28 -06:00
Tyler 6d2c90ae6b feat(admin): add MoreSheet (native dialog) with grouped secondary nav 2026-06-17 14:21:24 -06:00
Tyler ef565fbfb1 feat(admin): add MobileTabBar with 4 primary tabs + More trigger 2026-06-17 14:20:57 -06:00
Tyler 895defa453 feat(hooks): add useMediaQuery hook for responsive component logic 2026-06-17 14:20:31 -06:00
Tyler 9edbfc2e1b feat(admin): add AdminShell with breakpoint-aware layout 2026-06-17 14:20:11 -06:00
Tyler 6075d22a84 feat(admin): add OfflineBanner with online status + pending sync count 2026-06-17 14:19:32 -06:00
Tyler cc5c0e49be fix(offline): harden sync catch + add queue/sync test coverage + atomic transactions 2026-06-17 14:16:20 -06:00
Tyler d50ec4deda feat(offline): add sync dispatcher with backoff and conflict surfacing 2026-06-17 14:07:47 -06:00
Tyler d3b8e4f7cd feat(offline): add IndexedDB mutation queue (enqueue, dequeue, mark synced/conflict) 2026-06-17 14:06:42 -06:00
Tyler 6a9807a3be feat(offline): add IndexedDB schema for queue + cache 2026-06-17 14:03:14 -06:00
Tyler 182ccf2c72 feat(design): darken status tokens to pass AAA contrast
The previous color values did not actually meet WCAG AAA (7:1) on
all 4 page surfaces — the spec's contrast table was aspirational.
The contrast test correctly caught 19 of 37 failing assertions.

Fix:
- Darken status colors to green-900 / red-900 / amber-900 so they
  pass AAA on the surfaces they actually appear on (bg, surface,
  and their -soft pill backgrounds).
- Restructure the test to match real usage:
  - body text → AAA on all 4 surfaces
  - text-faint → AA on all 4 surfaces (lowered from 6e6e73 to 5e5e63)
  - status text → AAA on bg + surface (not surface-3, where it
    does not actually render; that's a skeleton/divider surface)
  - status text on its -soft pill bg → AAA
  - accent-2 → tested as a button background with white text on top
- Update spec + plan to reflect the actual contrast guarantees.

Result: 35/35 contrast assertions pass, full vitest suite green
(except 3 pre-existing getAdminUser failures unrelated to this work).
2026-06-17 13:59:18 -06:00
Tyler 6ffd07c54e fix(design): remove duplicate reduced-motion block + document token equivalences 2026-06-17 13:45:16 -06:00
Tyler 417379d1af feat(design): add Field Almanac tokens (type, color, spacing, motion) 2026-06-17 13:36:15 -06:00
Grok ead638d9ae chore: ignore .worktrees directory 2026-06-17 13:28:50 -06:00
Grok f00b327477 docs: implementation plan for admin mobile PWA (8 PRs, 46 tasks, 172 steps) 2026-06-17 13:26:55 -06:00
Grok 6545c2e0d4 docs: clarify rollout (no feature flag system) and sheet/dialog approach 2026-06-17 13:14:42 -06:00
Grok ddf82a0c66 docs: admin mobile PWA + HIG design spec 2026-06-17 13:13:06 -06:00
Tyler 7e079e0186 Show success banner after sending a password-reset email
Deploy to route.crispygoat.com / deploy (push) Successful in 4m8s
The 'Send Reset Email' button in /admin/users previously just
cleared the error banner on success, with no actual 'yes it
worked' feedback. Added a green success banner that mirrors the
error banner's style and auto-dismisses after 6 seconds.

The 'Reset Password' button already shows confirmation in the
modal (temp password to copy, or 'reset email sent' message), so
it doesn't need the banner.

Also tightened the type narrowing in the resetAdminPassword unit
tests — the discriminated union needed a two-step
narrow (`r.success` then `r.method`) before TypeScript would
allow access to variant-specific fields like `tempPassword`.
2026-06-17 12:31:38 -06:00
Tyler eb37df347e Fix admin password reset (Send Reset Email + Reset Password)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m14s
Both buttons in /admin/users were broken:

- "Send Reset Email" called a no-op stub in
  src/actions/admin/users.ts that always returned an error.

- "Reset Password" called resetAdminPassword with a hard-coded
  'Tuxedo2026!' password, and the function itself queried a
  non-existent `users` table and called a non-existent
  `update_user_password` RPC (leftover Supabase-era code).

Rewritten against Neon Auth:

- sendPasswordResetEmail(email) — platform_admin-only action that
  calls auth.requestPasswordReset with the configured
  NEXT_PUBLIC_SITE_URL + '/reset-password' redirect. Always returns
  a clear success/error.

- resetAdminPassword(email) — platform_admin-only action that:
  1. Looks up neon_auth.user by email
  2. Generates a strong server-side random temp password
  3. Tries auth.admin.setUserPassword first (instant credential,
     returned to the UI for the platform admin to share)
  4. On FORBIDDEN / UNAUTHORIZED / INTERNAL_SERVER_ERROR (the common
     case — provision-admin.ts does not promote callers to
     role='admin' in Neon Auth), falls back to
     auth.requestPasswordReset, which sends a reset link the user
     can click to set their own password.
  5. On the privileged path, flips admin_users.must_change_password
     so the user is forced to pick a real password on next sign-in.

UI updated to handle the new response shape (success-with-temp-
password vs success-with-reset-email-sent) with a fourth modal
state.

Tests: 19 new unit tests across both paths cover authz, input
handling, the privileged happy path, the FORBIDDEN/UNAUTHORIZED/
network-throw fallback, USER_NOT_FOUND surfacing, and the
'both paths fail' case. Full suite: 110/113 (3 pre-existing
getAdminUser failures unchanged).
2026-06-17 12:22:34 -06:00
Tyler 7e665ea43e Email service surfaces real Resend errors instead of silent false
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
The sendEmail / sendCampaignEmail / sendWelcomeEmail /
sendOrderReceiptEmail / sendPasswordResetEmail / sendOperationalAlert
helpers all returned Promise<boolean> and silently returned false on
any failure (missing API key, 401 invalid key, 422 unverified sender
domain, network error, etc.). The caller had no way to know which
problem it was.

Switch the public return type to a structured result:

  export type EmailSendResult = { ok: true } | { ok: false; error: string };

- Missing RESEND_API_KEY: error reads 'RESEND_API_KEY is not set.
  Add it to .env.local (or your hosting dashboard) and restart the
  server.'
- 4xx/5xx: error reads 'Resend 422 — validation_error: The gmail.com
  domain is not verified' (extracts Resend's own name + message).
- Network failure: error reads the thrown message verbatim.
- Non-JSON error body: falls back to 'Resend <status>'.

Callers updated:
  - src/actions/admin/users.ts (createAdminUser): now propagates the
    real emailError into the modal UI — no more generic 'sendWelcome
    Email returned false'.
  - src/actions/checkout.ts: logs the real error.
  - src/app/api/cron/send-scheduled/route.ts: per-recipient error
    logged for the scheduled-campaigns cron.

Tests:
  - tests/unit/email-service.test.ts (new, 5 tests): covers happy
    path, missing key, 4xx/5xx with JSON body, 4xx/5xx with non-JSON
    body, and network failure.
  - tests/unit/create-admin-user.test.ts: updated to use the new
    { ok, error? } return shape; new assertion that the action
    surfaces the real emailError string into the result.
2026-06-17 12:10:04 -06:00
Tyler 9d9bc5d257 Add 'Sign in with Google' button to /login and /wholesale/login
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
Wires up Better Auth's signIn.social({ provider: 'google' }) so users
can authenticate via Google OAuth. The flow is:

  1. User clicks the Google button.
  2. Client calls the signInWithGoogleAction server action.
  3. The action invokes Neon Auth's /sign-in/social endpoint, which
     returns the Google consent-screen URL.
  4. Client navigates the browser to that URL.
  5. Google redirects back through Neon Auth to the callbackURL.

Files:
  - src/actions/auth-actions.ts: new signInWithGoogleAction server
    action that wraps signIn.social and returns the redirect URL
    to the client. Structured { url, error } return — never throws.
  - src/app/login/LoginClient.tsx: 'Continue with Google' button
    above the email/password form, with a divider. Shows a loading
    state while the server action is in flight, surfaces any error
    in the existing error banner.
  - src/app/wholesale/login/page.tsx: same button for wholesale
    buyers. Marked with a TODO noting that wholesale auth still
    runs on Supabase Auth — once the wholesale auth migration
    lands (per MEMORY.md 'What's left'), the button will start
    working for them. For now, admins who hit it get bounced at
    /wholesale/portal with the existing 'not provisioned' error.
  - tests/unit/sign-in-with-google.test.ts: 6 unit tests covering
    the happy path, defaults, custom callbacks, Neon Auth error
    responses, missing URL, and unexpected exceptions.

Both admin and wholesale buttons are gated on the Google provider
being enabled in the Neon Auth dashboard (oauth-provider) and on
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET being set in the runtime
env — both are already wired through .gitea/workflows/deploy.yml.
2026-06-17 11:54:06 -06:00
Tyler d75380eb9a Create-user flow now provisions the Neon Auth account
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
Previously createAdminUser only inserted a local admin_users row and
emailed the password as plaintext — the user could not sign in because
the password was never set on a Neon Auth account.

This change makes the create flow:
  1. Authorize: only platform_admin can mint new admin users.
  2. Create the Neon Auth user via auth.admin.createUser, falling back
     to the public /sign-up/email + emailVerified=true pattern when
     the caller's Neon Auth session isn't an admin (the common case
     in dev — provision-admin.ts does not set neon_auth.user.role).
  3. Wrap the admin_users INSERT + admin_user_brands link in a
     transaction, returning an error if the local insert fails (the
     Neon Auth user is left orphaned and surfaced in the message).
  4. Send the welcome email best-effort, returning success/failure
     info to the UI.

The CreateUserModal now shows a success state with the temp password
(copy-to-clipboard), the welcome email status, and the auth path
used. The slide-in edit panel surfaces the password via window.alert
as a defense against the dead-code new-user path.

10 new unit tests cover authorization, the admin + signup paths, the
DB-failure orphan case, and the email best-effort behavior.
2026-06-17 11:46:39 -06:00
Tyler 11cd2fd01a Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Completes the Tuxedo Water Log feature end-to-end. Builds on the existing
water_* tables in 0001_init.sql; extends them with 0090 and rewrites the
server actions, settings page, export route, and admin panel to actually
function.

Schema (db/migrations/0090_water_log_completion.sql, db/schema/water-log.ts)
- headgate_token + status + max_flow_gpm + thresholds + notes on water_headgates
- role + phone + notes on water_irrigators
- method + total_gallons + photo_url + logged_date + lat/lon on water_log_entries
- new tables: water_admin_settings, water_admin_sessions, water_audit_log, water_alert_log

Auth (src/lib/water-log-pin.ts, src/lib/water-log-audit.ts)
- scrypt N=16384 per-PIN random salt, self-describing hash format
- weak-PIN detection (repeats, monotonic, palindromes)
- 200ms delay + timingSafeEqual on failed verify
- 4-8 digit PINs, generatePin() rejects weak patterns

Server actions (src/actions/water-log/{admin,field,settings}.ts)
- full Drizzle CRUD: headgates, irrigators, entries (with audit hooks)
- verifyPin + 8h field sessions, admin PIN + configurable 1-168h sessions
- regenerateAdminPin invalidates existing admin sessions
- threshold breach detection + alert log writes
- getWaterAdminSession() helper for admin pages

API routes
- /api/water-admin-auth: PIN-protected, generic error (no enum leak)
- /api/water-logs/export: admin-gated JSON/CSV, correct headers

UI (Field Almanac — cream + forest, Fraunces display, § section markers)
- src/components/admin/WaterLogAdminPanel.tsx: full rewrite
- src/components/water/WaterFieldClient.tsx: matches palette
- src/components/water/icons/{WaterGauge,SeasonMark}.tsx: custom SVGs
- src/app/admin/water-log/settings/page.tsx: full rewrite, Field Almanac

Tests
- tests/unit/water-log-pin.test.ts (21 cases): validate, hash, verify, generate
- tests/unit/water-log-reporting.test.ts (31 cases): season, filter, CSV, report
- tests/water-log.spec.ts (Playwright E2E): PIN form, auth gates, API gates

Docs
- docs/water-log.md: full admin guide, security model, data dictionary, checklist
- README: link to docs/water-log.md in admin modules list
- .env.example: Water Log section comment

Type-check: clean. Tests: 70 pass, 3 fail (3 pre-existing getAdminUser
failures on main, unrelated to this work).
2026-06-17 11:36:00 -06:00
Tyler 52c8a71cd0 MEMORY: note admin_users columns added in 0043 + vestigial flags
Deploy to route.crispygoat.com / deploy (push) Successful in 4m0s
2026-06-17 11:23:13 -06:00
Tyler e5db66e74a Add admin_users columns needed by create-user flow
Deploy to route.crispygoat.com / deploy (push) Successful in 4m27s
The create-user action in src/actions/admin/users.ts was written
against a richer admin_users schema than the one that landed in
0001_init.sql. The 0001 schema has just `name` plus the role-derived
can_manage_<X> columns; the action also references display_name,
phone_number, brand_id, can_manage_pickup/messages/refunds/users,
active, must_change_password, auth_provider, auth_subject, and
last_login. Result: 'column "display_name" of relation
"admin_users" does not exist' on any create-user submit.

Migration 0043 adds the missing columns with sensible defaults
(ADD COLUMN IF NOT EXISTS, so re-runnable). The four extra
can_manage_* flags are vestigial — getAdminUser() in
lib/admin-permissions.ts derives per-user permissions from the role
via permissionsForRole() and never reads them — but they exist so
the create-user form's per-user toggles persist, and the migration
header documents the cleanup target.

The db/schema/brands.ts Drizzle adminUsers definition is extended
in lockstep so the inferred TS types stay in sync with the table.
brand_id is left as a denormalized column (the link table
admin_user_brands is the source of truth and is what
getAdminUser() reads); getAdminUsers() in the action joins on
au.brand_id so keeping the column avoids rewriting the SELECT.

Gitea CI runs scripts/migrate.js before the build, so this lands
on prod automatically on the next push.
2026-06-17 11:22:43 -06:00
Tyler 365609d518 Restyle /admin/shipping to match the admin design system
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
- page.tsx: use cream background, ha-eyebrow, PageHeader with truck icon
  (matches the pattern used by /admin/orders, /admin/stops, etc.)
- ShippingFulfillmentPanel.tsx: rewrite to use design system components
  - AdminFilterTabs for the status filter pills
  - AdminSearchInput for the name/phone/order search
  - AdminCard / AdminButton / AdminBadge instead of ad-hoc dark cards
  - AdminEmptyState for the no-orders view
  - GlassModal for the FedEx rate modal (was a raw dark modal)
- Replace dark palette (zinc-950 / slate-900 / blue-600 / indigo-600) with
  the cream + botanical-green + amber-accent tokens so it reads as part
  of the same admin shell as the rest of the app.
2026-06-17 11:11:08 -06:00
Tyler a6df4c2dd9 Fix shipping + dashboard + analytics queries against new schema
Deploy to route.crispygoat.com / deploy (push) Successful in 4m48s
The /admin/shipping page was 500-ing with 'column c.name does not
exist' because the getShippingOrders query in src/actions/shipping.ts
was still using legacy column names on the new-schema customers
table (name, email, phone) — the actual columns are first_name,
last_name, primary_email, primary_phone.

The dashboard and analytics pages were logging the same kind of
errors but catching them, so the UI just showed zeroed stats instead
of crashing. Same fix:

- customers: name → first_name || ' ' || last_name
- customers: email → primary_email
- customers: phone → primary_phone
- orders: subtotal → total_cents / 100
- orders: created_at → placed_at
- orders: customer_name → join customers and concat first/last

These are the same kind of fixes that landed in migration 0041 for
the command-center RPCs. The application-layer queries just hadn't
been updated.
2026-06-17 11:00:34 -06:00
Tyler 4b781d3c76 Disable named view transition on admin page-content wrapper
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
The <ViewTransition name="page-content"> wrapper around the admin
main content was triggering a 'shared element' morph on every page
load and navigation: the browser tries to animate the bounding box
of the named element across the old and new pages, and since each
admin page has a different height, the browser was scaling the
snapshot from the top-left corner. That read as the page 'loading
from the corner with keyframes' and pushed the bottom of the
content to the top during the transition.

Setting update="none" opts out of the named transition entirely.
The children just swap on navigation — no fade, no scale, no morph.
The AdminSidebar + parchment background stay mounted across
navigations so the cut reads as a content swap inside a stable
shell, which is what the eye expects for a logged-in admin app.

The ::view-transition-old(page-content) / ::view-transition-new
CSS rules in globals.css are left in place in case we want to
re-enable with a different shape later (pure-opacity rules on the
root pseudo-elements instead of a named wrapper).
2026-06-17 10:40:46 -06:00
Tyler 0a534222e8 Remove command-center page and dependencies
Deploy to route.crispygoat.com / deploy (push) Successful in 4m20s
The /admin/command-center route was platform_admin-only, used a heavy
custom theme (Major Mono / JetBrains / Inter Tight + ~700 lines of
schematic CSS), called three RPCs, and overlapped with the per-brand
dashboards reachable at /admin, /admin/orders, /admin/stops, and
/admin/reports. The page is being removed; it didn't justify the
build complexity, font payload, or schema surface area.

Changes:
- Drop page, dashboard component, CSS, and server actions
- Remove the platform_admin nav entry from header + sidebar
- Remove the cmd+K palette entry
- Add migration 0042 to drop the 3 RPCs, founder_pain_log table,
  and founder_pain_log_platform view it owned
- Decrement route count in docs/pricing-assessment.md (88 -> 87)

Verification: tsc clean, eslint clean, npm run build clean, command-
center absent from the route list.
2026-06-17 10:18:33 -06:00
Tyler 3d4b98d703 fix command-center RPCs against prod schema and setof call
Deploy to route.crispygoat.com / deploy (push) Successful in 4m17s
Migration 0040 was written against an assumed schema and referenced
columns that don't exist in the prod orders table:
  - orders.created_at  → use orders.placed_at
  - orders.subtotal    → use orders.total_cents / 100
  - stops.date regex   → column is already DATE, not TEXT, drop the
                         ~ '^\d{4}-...$' check

Migration 0041 fixes both broken functions in place via
CREATE OR REPLACE FUNCTION.

The action layer at src/actions/platform/command-center.ts used
SELECT fn() AS "fn" for all platform RPCs, but two of the three
return TABLE(...) (setof) — the correct syntax is SELECT * FROM fn().
The platformRPC<T> helper now takes a kind: 'scalar' | 'setof' flag
and emits the right SQL.

Verified on prod:
  METRICS:    { orders_today:0, active_brands:1, active_routes:538, ... }
  BRAND_HEALTH: 1 row (Tuxedo Corn), 538 active_stops, 0 failed, healthy schema
  ACTIVITY:   0 rows (operational_events has no brand_id in payload, no error)
2026-06-17 10:02:46 -06:00
Tyler 4f22da65d8 redesign command center as operations schematic
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
Aesthetic shift from generic dark glassmorphism to an industrial
mission-control look: phosphor amber on near-black, Major Mono Display
for the wordmark, JetBrains Mono for all labels/numbers/codes, Inter
Tight for body copy.

- Top status bar with live clock, pulsing LIVE LED, and scrolling brand
  ticker (plan tiers + slugs)
- Serial-numbered sections (// 01-07) with CAD-style corner crosshairs
  and scale-rule dividers
- 6 KPIs in a flush 1px-separated grid with tabular numerals, sparklines,
  and per-cell M.0N serials
- AI briefing as a numbered readout with model metadata sidebar
- Brand health panels with severity LEDs, vertical dividers between
  metrics, and open-pain indicators
- Activity feed as terminal-style rows with color-coded event codes
- Pain log items with severity LEDs and P.NNN serials
- Quick Access links with L.NN prefixes and slide-on-hover
- Loading state replaced with terminal-style blinking bar + 'BOOT
  SEQUENCE' eyebrow
- 'Connection Lost' replaced with 'SIGNAL LOST · Platform Disconnected'
  panel that surfaces the actual error, reconnect action, and a
  diagnostic hint footer
- prefers-reduced-motion respected across ticker, LED pulse, and
  boot-reveal animations
- TV Mode scales up the title, KPIs, and briefing for wall display
2026-06-17 09:28:44 -06:00
Tyler 9da9c8b6e0 ci(deploy): add workflow_dispatch trigger for manual runs
Deploy to route.crispygoat.com / deploy (push) Successful in 3m47s
Lets the migration (and the rest of the deploy) be triggered on demand
from the Gitea UI instead of waiting for a push to main.
2026-06-17 09:05:06 -06:00
Tyler 5902d2b360 fix(admin): restore command center — add missing founder_pain_log table/view + 3 RPCs
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
The /admin/command-center page calls 3 RPCs and a table that only existed in
the archived Supabase migrations (126/127). The active db/migrations/ directory
had no replacement, so prod was missing these objects and the page threw
'Connection Lost' on every load (fetchAll() Promise.all rejects on missing RPCs).

Un-archives the original SQL into a single new migration (0040_command_center.sql)
with idempotent CREATE OR NOT EXISTS / CREATE OR REPLACE so it's safe to apply
to DBs that may already have some of the objects.
2026-06-17 09:03:49 -06:00
Tyler 36e13ba7fa motion: calm admin tab/drawer/popover/toast transitions
Deploy to route.crispygoat.com / deploy (push) Successful in 4m15s
Follow-up to 9fcc514. The global motion pass hit the public site;
this targets the admin design system, which had its own animation
budget that was still producing positional movement on every
interaction.

Killed:
- TabSwitcher y:4 slide (every tab change in /admin)
- CommandPalette scale(0.98) panel pop
- ha-drawer-in: 100% translateX (full-screen sweep)
- ha-popover-in: translateY(-4px) + scale(0.98)
- ha-check-pop: cubic-bezier(0.34, 1.56, 0.64, 1) 1.15x overshoot
- slide-in-from-right: 100% translateX (toasts)
- dashboard-fade-up: 10px Y slide on every card
- tailwindcss-animate keyframes for slide-in-from-{left,right,top,bottom} and zoom-in-{90,95} (used by ToastContainer, LotDetailPanel)

Calmed:
- All durations cut to 120-200ms range with ease-out
- All keyframes that survived are opacity-only

Added a @media (prefers-reduced-motion: reduce) block scoped to
the admin selectors so users with that OS setting see instant
transitions across toasts/drawers/popovers/dashboard cards.
2026-06-17 08:48:17 -06:00
Tyler 9fcc514045 motion: calm screen transitions to reduce motion sickness
Deploy to route.crispygoat.com / deploy (push) Successful in 4m30s
The site had a lot of aggressive motion that compounded into a
vertigo-inducing experience. Visual design (colors, type, layout) is
unchanged — only the movement has been calmed.

Single-commit overview:

- Route transitions: 90/140ms pure-opacity crossfade (was 220ms with
  4-6px Y-shift on enter/exit).
- atelier-* modal animations: 180ms opacity-only, 4px max (was 420ms
  with 20px slide + scale(0.96→1) and 80-440ms cumulative stagger).
- Hover transforms: -1px lift or no lift (was -2px + scale(0.98)).
- CTA shimmer: 400ms (was 700ms).
- Toggle thumb: ease-out (was cubic-bezier(0.34, 1.56, 0.64, 1) bouncy
  overshoot).
- GSAP ScrollAnimations: capped at 12-16px translation, 320ms duration,
  power1.out, reduced-motion guards at the top of every effect.
  ParallaxLayer no longer scrolls content; only the data-parallax
  attribute can opt in, and only to 24px.
- TuxedoVideoHero: killed 80px scroll-driven Y-shift on hero, killed
  video 1.15 scale-on-scroll, killed parallax-float scroll effect, cut
  hero-reveal to 8px/320ms (was 40px/1s/power3.out), removed the
  motion.scale on the logo and CTA buttons, slowed the bouncing
  scroll indicator from 1.5s to 2.4s.
- CinematicShowcase: killed morphing product cards (rotateY ±5°,
  scale 0.95→1.02), killed parallax background HARVEST text (-100px),
  killed translateX carousel, killed scale(0.9→1) back.out(1.4) reveal
  in favor of opacity-only 8px/320ms entrance, removed progress-dot
  scale, removed progress-bar transition lag.
- OnboardingFlow: removed scale(0.9→1) and y:20→0 entrance animations.
- Global MotionConfig: caps every framer-motion animation in the tree
  to 0.2s easeOut, and sets reducedMotion='user' so framer-motion
  automatically strips x/y/scale/rotate from all 71 motion.div reveals
  across the public site when the OS prefers-reduced-motion is set.
- globals.css prefers-reduced-motion block: comprehensive kill switch
  that disables animation/transition duration app-wide, wipes the
  route view-transition, and clears the .parallax-float / .hero-reveal
  transforms.

How to test:
- Default: motion is calmer, ~10x faster, with no parallax
- OS-level 'reduce motion' on: zero positional movement, opacity fades
  only.

Files changed: 7 (no new files)
2026-06-17 08:35:29 -06:00
Tyler 7047e086d6 chore(design): mark Phase 5 (Orders, Products, Stops) done
Deploy to route.crispygoat.com / deploy (push) Successful in 5m24s
2026-06-17 00:49:08 -06:00
Tyler 1e0e278451 feat(admin): apply new design system to Products pages
- List page: new PageHeader with 'OPERATIONS' eyebrow + 'Add product' CTA
- New/Edit pages: PageHeader with eyebrow, icon, status badge (edit)
- ProductsClient: EmptyState for zero products, AdminBadge for status,
  Fragment Mono on stats, token-based colors throughout
- ProductTableBody: AdminBadge tones for status/taxable, token-based
  hover/selected states, Fragment Mono on price column
- NewProductForm / ProductEditForm / ProductFormModal: surrounding
  chrome on tokens; save bars use .ha-btn-primary / .ha-btn-ghost
- .atelier-* body preserved as editorial treatment
2026-06-17 00:48:26 -06:00
Tyler 467f7e63fd feat(admin): apply design system to Orders list/detail/edit/table
- Add PageHeader + Operations eyebrow on list page
- Detail page: PageHeader with order-id/date eyebrow, customer title,
  total/status subtitle, AdminBadge tone for pickup + payment processor
- AdminOrdersPanel: KPIStat for stat cards, EmptyState with Create your
  first order CTA, AdminBadge tone for status pills, lucide-react icons,
  all hardcoded Tailwind colors replaced with var(--admin-*) tokens
- OrderTableBody: AdminBadge tone for status + pickup pills, all hardcoded
  colors replaced with tokens
- OrderEditForm: ha-field-label + ha-field-input / ha-field-textarea classes
  for form fields, ha-segment control for status buttons, semantic pickup
  toggle with primary/warning tokens, all hardcoded colors replaced

Behavior preserved end-to-end. Type-check clean.
2026-06-17 00:34:58 -06:00
Tyler 685a1269a4 feat(admin): apply design system to Stops pages
- Stops list, new, and detail pages use new PageHeader with Operations
  eyebrow, Stops & Routes title, and editorial subtitle.
- StopsCalendarClient: replace hardcoded amber (#f59e0b) and brown (#92400e)
  draft markers with --admin-warning / --admin-warning-soft tokens. Calendar
  structure unchanged (drag/click/edit preserved).
- Add/Edit/StopDetail modals: replace hardcoded red rgba with --admin-danger-soft
  and color-mix derived borders.
- StopsLocationsTabs: swap hardcoded emerald-* for --admin-primary /
  --admin-primary-soft tokens.
- StopMessagingForm: align with design system (ha-field, ha-eyebrow, tokens).
- StopProductAssignment error surface: tokens instead of red rgba.
- StopsHeaderActions already tokenized via AdminButton; no change.

TS clean. Calendar functionality unchanged.
2026-06-17 00:31:33 -06:00
Tyler 6c0a282765 feat(admin): unified command center dashboard
- KPI strip uses new <KPIStat> component (4 cards)
- New 'What needs attention' feed replaces 4 tabs of cards:
  * no orders today → suggest campaign
  * pending stops → link to stops
  * usage > 85% → warn
  * starter plan → upgrade prompt
- Quick actions + plan usage consolidated into one card
- Recent orders uses new <EmptyState>
- All 15 sections in a single grid (not 4 tabs), with 'All / Operations / Fulfillment / Management / Tools' filter
- 'Press ⌘K to search' hint under section grid
- All inline SVGs replaced with lucide-react
- All hardcoded colors replaced with new design tokens
2026-06-17 00:19:39 -06:00
Tyler 1a47bbea2f chore(design): mark Phase 2 + 3 done in checklist 2026-06-17 00:17:53 -06:00
Tyler 442c16d572 feat(admin): mount command palette + plumb addons to sidebar
- mount <CommandPalette /> in admin layout (Cmd+K)
- fetch getEnabledAddons(activeBrandId) for sidebar gating
- pass enabledAddons to AdminSidebar so Water Log / Route Trace hide when off
2026-06-17 00:17:32 -06:00
Claude 8e937344ff feat(admin): extract KPIStat, EmptyState, LoadingState primitives; tighten AdminBadge palette
Phase 2 pattern extraction from the design/ui-revamp-2026-06 spec.

- KPIStat: extract the stat card pattern from DashboardClient (used four
  times inline). Supports tone (default/primary/accent/warning/danger)
  and an optional trend indicator. Reuses .admin-stat-card* CSS classes.
- EmptyState: generic centered icon + title + description + single
  primary CTA. Action renders as <Link> when href is provided, else a
  <button>; both use .ha-btn-primary.
- LoadingState: skeleton list driven by .ha-skeleton. Renders an
  optional ha-eyebrow label above N rows (icon tile + label + value
  placeholders). Each row uses the new admin design tokens.
- AdminBadge: tighten variants to the new design tokens. Legacy
  variant= prop kept for back-compat (default/success/warning/danger/
  info); new tone= prop adds neutral/primary/accent/success. All
  colors now reference --admin-* tokens; no hardcoded hex values.
  Added a 1px tone-aware border for stronger definition on the cream
  canvas. AdminStatusBadge + AdminCountBadge updated to use the new
  tone prop directly.
- design-system/index.tsx: re-export KPIStat, EmptyState, LoadingState
  from the design-system barrel alongside AdminBadge.

npx tsc --noEmit clean.
2026-06-17 00:15:44 -06:00
Tyler 4ded68cec2 feat(admin): grouped sidebar IA 2026-06-17 00:13:28 -06:00
Tyler 750efdd318 feat(admin): add Cmd+K command palette component
Adds a self-contained <CommandPalette /> client component plus a
static data file that lists every admin page (per the new IA in
docs/superpowers/specs/2026-06-17-admin-redesign.md §4) and a
small set of quick actions.

Behavior:
- Toggles on Cmd+K (mac) / Ctrl+K (win/linux) at document level.
- Escape or backdrop click closes; Enter or click navigates.
- Fuzzy case-insensitive match on label / category / keywords,
  top 8 results, highlighted match substring.
- ↑/↓ keyboard nav, mouse hover updates selection, auto-focus
  on input when opened, body scroll lock while open.
- Visual: uses existing --admin-card-bg / --admin-border /
  --admin-primary-soft / --admin-radius-lg / --admin-shadow-lg
  tokens; fade-in 180ms, scale 0.98→1 animation.
- Returns null when closed (no DOM noise).

Out of scope for this pass:
- Component is NOT mounted in the admin layout (main thread wires
  it in). The data file is the source of truth; the palette does
  not import from AdminSidebar.
- localStorage-based Recent items are left as a TODO comment
  in the component; v1 ships without persistence.

No new dependencies; uses lucide-react (already in package.json).
npx tsc --noEmit is clean.
2026-06-17 00:13:23 -06:00
Tyler 18fb44ed38 style(admin): unify color tokens (deep botanical + amber)
- --admin-bg: warm cream (#FAF7F0)
- --admin-text-primary: ink black (#1A1814)
- --admin-text-muted: warm grey (#8A867E) — was too dim
- --admin-border: soft beige (#E8E4D7) — was too dirty
- --admin-primary: deep botanical (#1F4D2A)
- --admin-accent: warm amber (#B8761E) — the 'one thing to do'
- --admin-warning: amber (was #aba278 — looked like dirt)
- --admin-danger: warmer rust (#A8321C)
- sidebar: deeper, more refined (#2A2520)
- drop purple/blue/citrus stat icons in DashboardClient; use semantic tokens
- use --admin-warning instead of hardcoded #f59e0b in usage bar
2026-06-17 00:08:46 -06:00
Tyler bd2dadd9ee chore(design): admin redesign spec + task checklist
- design spec: aesthetic, color tokens, IA, phased plan
- checklist: 7 phases, every task has notes column, revert cheatsheet
- working branch: design/ui-revamp-2026-06
2026-06-17 00:07:04 -06:00
Tyler c8fa2e8b52 polish: consolidate typography to next/font variables, add atelier utility classes
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
- Remove Google Fonts @import from 4 components (SiteHeader, LandingPageWrapper,
  TestimonialsAndCTA, FeaturesAndStats) — eliminate render-blocking external
  request and font flash
- Replace all unloaded Cormorant Garamond / Playfair Display / DM Sans /
  Plus Jakarta Sans references with the existing next/font variables
  (Fraunces, Manrope, Fragment_Mono) — visual coherence with the design system
- Update 9 pages (blog, brands, changelog, maintenance, protected-example,
  roadmap, security, waitlist, WaitlistForm) to use the loaded Fraunces
- Fix dead --font-geist / --font-jetbrains-mono references in
  admin-design-system.css (now point to --font-manrope / --font-fragment-mono
  which are actually loaded)
- Add atelier-pill, atelier-numerals, atelier-fineprint, atelier-canvas-soft
  utility classes; .atelier-input:focus-visible refined ring
- Add .ha-field-textarea, .ha-scroll, .ha-skeleton to admin design system
- Extend prefers-reduced-motion guard to atelier-enter/stagger/shimmer
- Apply atelier-fineprint to login/not-found/error pages for consistency
- No structural changes; build passes, 0 type errors
2026-06-16 23:50:16 -06:00
Tyler 4ebbc6dacf feat: smooth view transitions, no skeleton flash
Deploy to route.crispygoat.com / deploy (push) Successful in 4m21s
User pain point: skeleton loading.tsx files made the app feel like
a sequence of page reloads, exposing backend latency. Replaced with
a single 1px shimmer bar + crossfade via React's <ViewTransition>.

Changes:
- Enable experimental.viewTransition in next.config.ts
- Add SmoothViewTransition wrapper (ViewTransition name=page-content)
- Add LoadingFade component: thin animated bar instead of skeleton
- Add RouteAnnouncer for a11y (screen readers + focus reset)
- Add ::view-transition-old/new CSS for the crossfade (220ms, no
  jarring slide, respects prefers-reduced-motion)
- Wrap admin/tuxedo/IRD layout children in SmoothViewTransition
  (sidebar/header/footer stay mounted; only the body fades)
- Replace 19 skeleton loading.tsx files with the fade component

Result: navigation now feels like a single app, not a series of
preload-and-render events. The user never sees a 'skeleton of the
page they're about to load.'
2026-06-16 23:37:00 -06:00
Tyler 9458fd0506 docs: add nested layout skeleton map
Deploy to route.crispygoat.com / deploy (push) Successful in 4m20s
Visualizes the current layout nesting in the App Router, identifies
gaps where shared sub-layouts could be added (cart/checkout, wholesale,
water, admin/communications, admin/settings), and shows how to add
a new nested layout or parallel route for modal-style navigation.
2026-06-16 23:29:16 -06:00
Tyler 83ad6536a3 feat: production-readiness pass
Deploy to route.crispygoat.com / deploy (push) Successful in 5m46s
- Migrate login page to atelier design system (editorial modal style)
- Polish root error.tsx, not-found.tsx, loading.tsx with atelier design
- Add JSON-LD structured data: SoftwareApplication + LocalBusiness
- Fix next.config.ts outputFileTracingRoot absolute path warning
- Add force-dynamic to protected-example (uses cookies)
- Add proper type for stops page (replace : any)
- Fix unescaped quotes in StopTableClient
- Fix no-explicit-any in db-schema route
- Clean up console.logs in admin-permissions
- Fix inline any in admin/stops page
- Update OG image references to existing og-default.svg
2026-06-16 23:11:35 -06:00
Tyler 244551ce70 feat(stops): align with products page design
Deploy to route.crispygoat.com / deploy (push) Successful in 4m4s
- Use StopTableClient in server page component
- Add view mode toggle (table/card) matching products page
- Match stats cards styling to products page
- Add consistent filter bar layout with AdminViewModeTabs
- Add card view for stops with inline delete confirm
- Improve row actions with Edit button + dropdown menu
- Add 'Today' badge and past stop dimming in card view
2026-06-10 14:59:42 -06:00
Tyler b1d4174721 feat(stops): add edit modal, sorting, pagination, and stats bar
Deploy to route.crispygoat.com / deploy (push) Successful in 4m3s
- Add EditStopModal for unified add/edit stop workflow
- Enhance StopTableClient with sortable columns (date, city, location, status)
- Add stats bar showing total, active, upcoming, and draft counts
- Improve pagination with 'Showing X-Y of Z' indicator
- Add 'Today' badge for current-day stops
- Dim past stops visually
- Add Edit action to row menu with icons
- Reduce page size to 25 for better UX
2026-06-10 14:45:41 -06:00
Tyler 001840ab05 fix: add brand picker to sidebar, use getActiveBrandId in stops page
Deploy to route.crispygoat.com / deploy (push) Successful in 4m11s
- AdminSidebar now renders BrandSelector when brands are available
- Stops page uses getActiveBrandId() instead of adminUser.brand_id
  directly, so platform admins see all stops and brand selection
  via the sidebar picker works correctly
- BrandSelector already existed and was wired up in the layout,
  just never rendered in the sidebar
2026-06-10 14:32:50 -06:00
Tyler f0a703794a diagnostic: simplify stops page to bare query + debug info
Deploy to route.crispygoat.com / deploy (push) Successful in 4m3s
2026-06-10 14:22:44 -06:00
Tyler 7d1e6f784b fix: deploy writes .env (not .env.production) so PM2 loads secrets
Deploy to route.crispygoat.com / deploy (push) Successful in 4m0s
2026-06-10 14:06:02 -06:00
Tyler b5f7252ac0 fix: use correct column names for stops (cutoff_date not cutoff_time) and replace mock Supabase with real pool queries in stops detail/new pages
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
2026-06-10 13:48:30 -06:00
Tyler 24cf9a7261 fix: remove non-existent deleted_at column from stops query
Deploy to route.crispygoat.com / deploy (push) Successful in 3m59s
2026-06-10 13:37:39 -06:00
Tyler 8428f3a490 Add pagination to admin stops page (50/page)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m37s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:29:44 -06:00
Tyler 77fb8fe7ee Fix stops query: use status column not deleted_at
Deploy to route.crispygoat.com / deploy (push) Successful in 3m33s
stops table has no deleted_at column — soft delete is done via status='active'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:22:50 -06:00
Tyler eabc709076 Fix admin stops page: replace mock supabase with direct pool query
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s
The page was importing from @/lib/supabase which is a mock client that
returns empty results. Replaced with pool.query() against the real
Postgres stops table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:17:39 -06:00
Tyler 4bd2ed0db2 Remove Tuxedo Corn tour seed step from deploy workflow
The seed step on every deploy was inserting duplicate stops each run.
The cleanup script (scripts/cleanup-duplicate-stops.ts) can be used to
dedupe existing rows when needed — run manually via:
  DATABASE_URL="..." npx tsx scripts/cleanup-duplicate-stops.ts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:17:34 -06:00
Tyler c1396096ad Use tsx import script instead of psql for tour seed in deploy
Deploy to route.crispygoat.com / deploy (push) Successful in 3m47s
psql with Neon pooler times out on large batch RPCs. The tsx script
replaces -pooler. with direct compute endpoint and uses pg Pool with
proper batching + progress output.
2026-06-10 12:53:39 -06:00
Tyler a53516bfe6 Fix TypeScript return type in import-tuxedo-stops.ts
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
Add missing 'locations' property to loadStops() return type.
2026-06-10 12:49:16 -06:00
Tyler fb23c21ad9 Add Tuxedo Corn 2026 tour seed to deploy workflow
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
Run db/seeds/2026-tuxedo-tour-stops.sql via psql after migrations in
the deploy step. Uses admin_create_locations_batch + admin_create_stops_batch
RPCs (migration 0003) to insert 40 locations + 269 stops for the Tuxedo Corn
2026 tour. Wrapped in BEGIN/COMMIT with DELETE-first semantics — idempotent.
2026-06-10 12:47:51 -06:00
Tyler 0cf2ee7948 Admin dashboard: fix stats waterfall, redesign layout, add animations
Deploy to route.crispygoat.com / deploy (push) Failing after 3m13s
- Fetch dashboard stats server-side in admin/page.tsx to eliminate
  client-side useEffect waterfall and the '—' placeholder flash
- Pass pre-fetched stats as props to DashboardClient
- Lazy-load UpgradePlanModal via next/dynamic (only loads when needed)
- Redesign stats cards with compact layout, smaller icons, Fraunces serif values
- Improve Quick Actions + Usage row: side-by-side on desktop, stacked mobile
- Clean up Recent Orders as a proper list with icon/name/time/amount/badge
- Add staggered fade-up entrance animations (0/60/120/180/240ms delays)
- Consolidate loading.tsx skeleton to match actual dashboard structure
- Mobile-responsive: 2-col stats on mobile, stacked usage, collapsible header
2026-06-10 12:45:08 -06:00
Tyler 6c1c616c1c frontend polish: add reduced-motion support, dynamic copyright, missing animation keyframes
Deploy to route.crispygoat.com / deploy (push) Successful in 4m1s
- LandingPageWrapper: Use dynamic year for copyright
- HeroSection: Add float-slow/float-slow-delayed keyframes, add prefers-reduced-motion media query
- TuxedoVideoHero: Add reduced-motion JS guard and CSS media query for accessibility
2026-06-10 11:20:25 -06:00
Tyler 4909a78aca stops import: swap OpenAI for MiniMax as default AI provider
Deploy to route.crispygoat.com / deploy (push) Successful in 5m27s
2026-06-10 10:58:37 -06:00
459 changed files with 26651 additions and 38852 deletions
+11
View File
@@ -68,3 +68,14 @@ MINIO_BUCKET_WATER_LOGS=route-water-logs
# ── Cron / automation ───────────────────────────────────────────────────────
CRON_SECRET=replace-me-with-a-random-string
# ── Water Log ───────────────────────────────────────────────────────────────
# The Water Log module reuses the existing `DATABASE_URL` and (for photo
# uploads) the `MINIO_BUCKET_WATER_LOGS` bucket above. It also reuses the
# brand's Twilio / SMS config for high/low threshold alerts. The
# Tuxedo brand UUID is a public default used as a fallback for cold-start
# paths when the calling admin user is a platform_admin with brand_id=null.
# It does NOT need to be set as an env var — the value is hardcoded in
# the server actions — but you can override it with:
# TUXEDO_BRAND_ID=64294306-5f42-463d-a5e8-2ad6c81a96de
# See docs/water-log.md for the full module guide.
+2 -1
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
@@ -176,7 +177,7 @@ jobs:
# Upload env file and sync build output
echo "Uploading env file..."
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no "$ENV_FILE" tyler@route.crispygoat.com:$APP_DIR/.env.production
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no "$ENV_FILE" tyler@route.crispygoat.com:$APP_DIR/.env
echo "Copying .next/..."
scp -o ConnectTimeout=30 -o StrictHostKeyChecking=no -r .next tyler@route.crispygoat.com:$APP_DIR/
+34
View File
@@ -0,0 +1,34 @@
name: PWA + Mobile A11y Audit
on:
pull_request:
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Start server
run: npm run start &
env:
PORT: 3000
- name: Wait for server
run: sleep 10
- name: Run Lighthouse CI
run: npx lhci autorun
+2
View File
@@ -47,3 +47,5 @@ playwright-report/
.mcp.json
.env*
public/videos/tuxedo-hero.mp4
.neon
.worktrees/
+44 -12
View File
@@ -12,13 +12,45 @@ There is exactly one remote — `origin` — pointing to the self-hosted Gitea r
Do **not** add GitHub remotes. There is no `origin` on github.com and no separate "dev" repo. If you see `github.com/dzinesco/*` URLs in `.git/config`, that is stale configuration from a previous fork and should be removed (`git remote remove`).
### Gitea authentication (SSH)
This workspace has been using Gitea via SSH for all operations. The Gitea instance is self-hosted at `git.crispygoat.com` (web UI: `https://git.crispygoat.com`, API base: `https://git.crispygoat.com/api/v1`).
**SSH key:** `~/.ssh/id_ed25519_crispygoat` is the dedicated key registered with the Gitea instance (alias `dog` per the Gitea SSH banner). Add it to the agent before any git operation:
```bash
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519_crispygoat
```
The SSH config (`~/.ssh/config`) already aliases the host as `crispygoat` with `AddKeysToAgent yes`, so subsequent shells can just `ssh crispygoat` / `git push origin ...` without re-adding.
**Verified working commands:**
- `git push origin <branch>` — push (this is the primary workflow)
- `ssh -p 22 git@git.crispygoat.com` — auth check; Gitea returns "successfully authenticated" + "Gitea does not provide shell access"
**API token:** This environment does **not** have a Gitea personal access token in any known location (no `~/.config/gitea`, no `GITEA_ACCESS_TOKEN` env, no token in `~/.config/gh/hosts.yml`). Do not assume a token is available — auth attempts will return `{"message":"invalid username, password or token"}`.
**`tea` CLI / `gitea-mcp`:** Both are installed but require a token; `--ssh-agent-key` is recognized by `tea logins add` but the underlying SDK still falls back to requiring a token. Treat these as unavailable until a token is provisioned.
### Opening pull requests (the workflow that actually works)
Because no Gitea API token is available in this environment, the PR creation flow is:
1. Create a feature branch: `git checkout -b docs/<date>-<description>`
2. Commit the changes (no need to commit unrelated files — leave the worktree dirty but `git add` only what you intend to ship)
3. Push: `git push -u origin <branch>`
4. The Gitea push hook prints a "Create a new pull request" URL on stderr, e.g. `https://git.crispygoat.com/tyler/route-commerce/pulls/new/<branch>` — open that URL in a browser to file the PR through the web UI.
If a future environment provides a Gitea token, the `tea pr create` and `curl POST /api/v1/repos/tyler/route-commerce/pulls` flows become available; the auth pattern is `Authorization: token <TOKEN>` on the REST API or `tea logins add -t <TOKEN> -u https://git.crispygoat.com` for the CLI.
## Project Overview
Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.
Tech stack: Next.js 16 (App Router) · **Postgres** (direct) · **Neon Auth** (Better Auth) · Stripe · Square · Resend (email) · Tailwind CSS v4
> **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST.
> **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code must connect to Postgres directly via the shared `pg` `Pool` from `src/lib/db.ts`. No Supabase JS client, REST gateway, or `*.supabase.co` calls anywhere in the codebase.
---
@@ -33,12 +65,12 @@ npx tsc --noEmit # TypeScript check (no emit)
npx playwright test # Run E2E tests (Playwright)
```
> The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies.
> The migrate script is `scripts/migrate.js`. It reads `DATABASE_URL` (or `DATABASE_ADMIN_URL`) from `.env.local` via `dotenv` and applies any `db/migrations/*.sql` files not already recorded in `_migrations`. `pg` is already in devDependencies.
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details.
**Historical migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for the modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed). Cat `MEMORY.md` for details.
E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config.
E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:4000` for local runs (the dev server binds to port `4000`), or pass `--config` with a local config.
---
@@ -51,12 +83,12 @@ E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.sp
**Auth flow:**
1. User signs in via `/api/auth/sign-in` → Neon Auth validates credentials → session cookie set
2. `getAdminUser()` in `src/lib/admin-permissions.ts` reads the Neon Auth session and looks up `admin_users` by email
3. Middleware (`src/middleware.ts`) guards `/admin/*` routes at the edge level
3. Middleware (`src/proxy.ts`) guards `/admin/*` routes at the edge level
**Key files:**
- `src/lib/auth.ts` — Neon Auth configuration (getSession, signIn, signOut, resetPassword, requestPasswordReset)
- `src/auth.config.ts` — Edge-safe config (baseUrl, cookieSecret)
- `src/middleware.ts` — Edge-level route protection
- `src/proxy.ts` — Edge-level route protection
- `src/app/api/auth/sign-in/route.ts` — Email/password sign-in API
- `src/app/api/auth/forgot-password/route.ts` — Password reset request API
- `src/app/api/auth/reset-password/route.ts` — Password reset confirmation API
@@ -93,13 +125,13 @@ Server actions are "use server" files that export async functions. Client compon
### Database (Postgres, direct)
The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver to call `SECURITY DEFINER` PL/pgSQL functions; Drizzle (on top of the same pool) handles typed reads and the per-request `app.current_brand_id` GUC. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
#### Connection
- `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var.
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names.
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase.
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` as `pool` (proxy) and `getPool()` (lazy). Server actions and API routes import it and call `pool.query(...)` against RPC names. The Drizzle client lives at `db/client.ts` and shares the same pool.
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — none exist anywhere in the codebase.
#### First production deploy / new prod DB bootstrap (critical for admin access)
@@ -255,7 +287,7 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
### Communications Module ("Harvest Reach")
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.)
The communications system (`/admin/communications`) uses a separate set of tables that have **no row-level policies** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. Scoping is enforced by RPC + app layer.
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
@@ -282,7 +314,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Key Conventions
- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`.
- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports, REST fetches, or `*.supabase.co` calls anywhere in the codebase.
- `gen_random_uuid()` used in migrations for primary keys
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
- Status enums stored as TEXT — no PostgreSQL ENUM type
@@ -300,7 +332,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
| Neon Auth configuration | `src/lib/auth.ts`, `src/auth.config.ts` |
| Auth API routes | `src/app/api/auth/sign-in/route.ts`, `src/app/api/auth/forgot-password/route.ts`, `src/app/api/auth/reset-password/route.ts`, `src/app/api/auth/[...nextauth]/route.ts` |
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
| Middleware (route protection) | `src/middleware.ts` |
| Middleware (route protection) | `src/proxy.ts` |
| Server actions | `src/actions/*.ts` (one file per domain; also grouped into `src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/`) |
| Admin pages | `src/app/admin/[module]/page.tsx` |
| Admin client components | `src/components/admin/*.tsx` |
+11 -17
View File
@@ -10,18 +10,9 @@ Copy `.env.example` → `.env.local` and fill in the values.
These are safe to commit and can be used in client bundles. Prefix with `NEXT_PUBLIC_`.
### `NEXT_PUBLIC_SUPABASE_URL`
Your Supabase project URL.
- **Where to get:** Supabase Dashboard → Project Settings → API → Project URL
- **Example:** `https://abc123.supabase.co`
### `NEXT_PUBLIC_SUPABASE_ANON_KEY`
Supabase anonymous (anon) key — safe for client-side use.
- **Where to get:** Supabase Dashboard → Project Settings → API → anon key
### `NEXT_PUBLIC_BASE_URL`
The base URL of your deployment. Used for OAuth redirects and webhook URLs.
- **Local:** `http://localhost:3000`
- **Local:** `http://localhost:4000` (dev script binds to port 4000; override via `npm run dev -- -p <port>`)
- **Production:** `https://yourdomain.com`
### `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
@@ -40,12 +31,16 @@ Set to a brand slug to hide other brand routes in single-brand mode.
**Never prefix with `NEXT_PUBLIC_`**. These must only be accessed in Server Components, Server Actions, or API Routes.
### Supabase
### Database
#### `SUPABASE_SERVICE_ROLE_KEY`
Full service role key — grants DB admin access bypassing RLS.
- **Where to get:** Supabase Dashboard → Project Settings → API → service_role key
- **⚠️ Critical:** Never expose this to the client. Used only in server-side code.
#### `DATABASE_URL`
Full Postgres connection string. The app uses this exclusively (no Supabase, no JS client) for every server-side query — server actions and API routes import a shared `pg` `Pool` from `src/lib/db.ts` and call `SECURITY DEFINER` PL/pgSQL functions.
- **Where to get:** Neon Dashboard → Project → Connection Details → Connection string (pooled)
- **Example:** `postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/routecommerce?sslmode=require`
- **Format:** `postgres://user:pass@host:port/dbname?sslmode=require`
#### `DATABASE_ADMIN_URL` (optional)
Same connection string but with elevated perms (typically the direct, non-pooled Neon connection). Only used by `scripts/migrate.js` for DDL — fall back to `DATABASE_URL` if unset.
### Stripe
@@ -156,7 +151,7 @@ Controls whether to use Square sandbox or production.
| Variable | Local (`.env.local`) | Production (hosting dashboard) |
|---|---|---|
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | `https://yourdomain.com` |
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:4000` (or whatever port the dev server is on) | `https://yourdomain.com` |
| `STRIPE_SECRET_KEY` | `sk_test_...` | `sk_live_...` |
| `STRIPE_PUBLISHABLE_KEY` | `pk_test_...` | `pk_live_...` |
| `SQUARE_ENVIRONMENT` | `sandbox` | `production` |
@@ -168,7 +163,6 @@ Controls whether to use Square sandbox or production.
- **Wrong Stripe key type:** Using `sk_live_` in development or `sk_test_` in production will silently fail. Always match key type to environment.
- **Stripe price IDs drift:** If you create new prices in Stripe Dashboard but forget to update `.env.local`, billing will fail. Keep them in sync.
- **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type.
- **SUPABASE_SERVICE_ROLE_KEY in client:** If you ever see `SUPABASE_SERVICE_ROLE_KEY` in a browser bundle, it's a critical security incident. The key was exposed server-side. Rotate it immediately in Supabase Dashboard.
- **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key.
---
+10 -8
View File
@@ -1,5 +1,7 @@
# Route Commerce Launch Checklist Summary
**Last updated:** 2026-06-25
## Overview
This document summarizes the complete Launch & Marketing Layer implementation for Route Commerce, a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.
@@ -8,15 +10,15 @@ This document summarizes the complete Launch & Marketing Layer implementation fo
## ✅ Implementation Status
### 1. Supabase Foundation ✓
### 1. Database & Auth Foundation ✓
| Component | Status | Location |
|-----------|--------|----------|
| Auth Flow | ✓ Complete | Existing dev_session + Supabase Auth |
| Database Structure | ✓ Complete | Supabase migrations in place |
| Role-Based Access | ✓ Complete | admin-permissions.ts system |
| Protected Routes | ✓ Complete | middleware.ts |
| Server Actions Pattern | ✓ Complete | src/actions/*.ts |
| Auth Flow | ✓ Complete | Neon Auth (Better Auth) + dev_session for local |
| Database Structure | ✓ Complete | `db/migrations/` applied via `pg` |
| Role-Based Access | ✓ Complete | `admin-permissions.ts` system |
| Protected Routes | ✓ Complete | `src/proxy.ts` (Next.js 16+ middleware convention) |
| Server Actions Pattern | ✓ Complete | `src/actions/*.ts` |
### 2. Launch-Ready Features ✓
@@ -237,9 +239,9 @@ Track these metrics post-launch:
- **Documentation**: See CLAUDE.md for technical details
- **Stripe Dashboard**: https://dashboard.stripe.com
- **Supabase Dashboard**: https://app.supabase.com/project/wnzkhezyhnfzhkhiflrp
- **Neon Console**: https://console.neon.tech
- **Vercel Deployments**: https://vercel.com/dashboard
---
*Generated: January 2025*
*Generated: January 2025 · Refreshed: 2026-06-25 (see `CLAUDE.md` for current architecture; deployment is via Vercel hooked to the Gitea `origin` remote — there is no GitHub `origin`)*
+57 -46
View File
@@ -2,7 +2,21 @@
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
**Last updated:** 2026-06 (migration reliability + Google sign-in work)
**Last updated:** 2026-06-25 (added current-state header; restored "Supabase → Postgres" pivot as the canonical entry point for new readers; documented the SSH-based Gitea workflow)
> **Current state (read first).** Auth is **Neon Auth (Better Auth)** — see `CLAUDE.md`. The "Auth.js v5 wiring complete — 2026-06-06" entry further down is **historical** (it describes work that was subsequently replaced by the Neon Auth migration; `next-auth` remains in `package.json` as a vestigial dep but is no longer imported from `src/`). The Supabase → Postgres pivot section below is the canonical cutover record. The "GitHub origin" branch notes are also historical — the only remote is the Gitea `origin` (see `CLAUDE.md` "Canonical Remote"). Middleware lives at `src/proxy.ts`, not `src/middleware.ts` (Next.js 16+ convention). Repo operations use **SSH** (`~/.ssh/id_ed25519_crispygoat`) — no Gitea API token is provisioned in this env, so `tea`/`gitea-mcp`/REST API are unavailable for write operations; PRs are opened by `git push` + clicking the URL the Gitea push hook prints.
## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043)
The 0001 schema's `admin_users` table has just `name` plus the role-derived `can_manage_*` columns (`can_manage_orders`, `can_manage_products`, `can_manage_stops`, `can_manage_customers`, `can_manage_wholesale`, `can_manage_billing`, `can_manage_settings`, `can_manage_water_log`, `can_manage_time_tracking`, `can_manage_route_trace`, `can_manage_reports`, `can_manage_communications`).
But `src/actions/admin/users.ts` was written against a richer schema: it INSERTs and SELECTs `display_name`, `phone_number`, `brand_id`, `can_manage_pickup/messages/refunds/users`, `active`, `must_change_password`, `auth_provider`, `auth_subject`, `last_login`. Submitting the create-user form produced `column "display_name" of relation "admin_users" does not exist`.
Migration `db/migrations/0043_admin_users_extra_columns.sql` adds all twelve missing columns (`ADD COLUMN IF NOT EXISTS`, re-runnable). `db/schema/brands.ts`'s Drizzle `adminUsers` definition was extended in lockstep.
**Important**: the four extra `can_manage_*` flags (pickup / messages / refunds / users) are **vestigial**`getAdminUser()` in `lib/admin-permissions.ts` derives per-user permissions from the role via `permissionsForRole()`, never reads those columns. They exist only so the create-user form's per-user permission toggles persist; they have no runtime authorization effect. Cleanup target: either (a) read them in the role-derived lookup to support real per-user overrides, or (b) drop them from the form.
The `brand_id` column on `admin_users` is a **denormalization** of the `admin_user_brands` link table (which is the source of truth, and is what `getAdminUser()` reads). The action's `getAdminUsers` joins on `au.brand_id` so keeping the column avoids rewriting that SELECT. Future cleanup can drop the denormalized column and migrate the SELECT to join through the link table.
## 2026-06: CI migration failures on re-deploy (0001_init.sql "already exists")
@@ -36,80 +50,79 @@ See also the plan doc referenced in deploy.yml for the broader reliability work.
## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture.
**Status: COMPLETE (2026-06).** All Supabase references removed from the codebase:
- `supabase/` directory deleted (`config.toml`, `push-migrations.js`, `ADMIN_CREATE_STOP_FIX.sql`, and 137 archived migration files in `supabase/migrations/.archived/` are gone).
- `@supabase/ssr` and `@supabase/supabase-js` removed from `package.json`.
- `next.config.ts` no longer whitelists `*.supabase.co` for images; `vercel.json` CSP no longer allows `https://*.supabase.co` in `connect-src`.
- `eslint.config.mjs` no longer ignores `supabase/**`.
- All `src/`, `tests/`, and `db/` files are free of `@supabase/*` imports, `rest/v1/` calls, and `*.supabase.co` references.
- Migrations are now applied by `scripts/migrate.js` (which uses `pg` + `DATABASE_URL`); the Supabase CLI branch is gone with the deleted script.
### What changes immediately
- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`.
- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**).
- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth.
- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**.
This section is kept as **historical record** of the cutover. The "Supabase CLI + Migrations Tooling" section further below is also historical.
### What's TBD / needs follow-up
- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet)
- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided**
- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md)
- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings?
- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition
- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally?
### Original pivot summary (kept for context)
The platform moved off Supabase entirely. We connect to **Postgres directly** (via `pg`), with **Neon Auth (Better Auth)** handling authentication. See `CLAUDE.md` for the current architecture.
### What changed at the time
- **DB connection**: `DATABASE_URL` (with `DATABASE_ADMIN_URL` optional override for DDL) is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
- **Migrations**: `scripts/migrate.js` is the canonical runner. It reads `DATABASE_URL` from `.env.local` via `dotenv`, tracks applied files in `_migrations`, and applies any new files in `db/migrations/` in lexical order inside a transaction.
- **Code**: zero `@supabase/*` imports, zero `rest/v1/` REST fetches, zero Supabase JS client usage. One shared `pg` `Pool` in `src/lib/db.ts`; Drizzle on top of it at `db/client.ts` for typed reads + RLS-style brand scoping.
- **Auth**: Neon Auth (Better Auth) is the production path. `dev_session` cookie is the local-only bypass.
- **Storage**: object store migration (S3-compatible) is independent of the Supabase purge and is tracked separately.
### Migration content that's now obsolete
- **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice.
- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world.
- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
- **Any RLS policy on tables** (200 added several): the "no row-level policies, app-layer scoping" model still holds but the policies are inert in the new world.
### Historical sections below
The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
The "Supabase CLI + Migrations Tooling" section further down describes the *previous* tooling. It is kept as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use `scripts/migrate.js` (or `npm run migrate`) instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
---
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)*
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above; script and CLI are no longer in the repo)*
### Login + Link (done in this session)
- User ran `supabase login`
- We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
- Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`.
- Link state lives under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root.
- This enables `supabase db query --linked`, `supabase migration list`, etc.
- Link state lived under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root.
- This enabled `supabase db query --linked`, `supabase migration list`, etc.
### Important Environment Note
- Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace:
- `getaddrinfo ENOTFOUND`
- IPv6 "network is unreachable" in some cases
- HTTPS / Supabase REST / Management API work fine.
- Therefore the `push-migrations.js` **CLI path** (using linked project) is the only reliable way to apply migrations here.
- Therefore the `push-migrations.js` **CLI path** (using linked project) was the only reliable way to apply migrations here.
### Updated Migration Script
File: `supabase/push-migrations.js`
### Migration Script (deleted 2026-06 — use `scripts/migrate.js` instead)
File: `supabase/push-migrations.js` *(deleted)*
Key changes:
- Detection logic now recognizes modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
- `pushWithCli()` rewritten to apply files one-by-one using:
Key behavior (historical):
- Detection logic recognized modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
- `pushWithCli()` applied files one-by-one using:
```bash
supabase db query --linked --file "supabase/migrations/NNN_foo.sql"
```
(Previously tried `db push --db-url ...` which used direct connection and failed here.)
- Falls back to direct `pg` only if CLI path fails.
- Header comments updated with current recommended workflow.
- Fell back to direct `pg` only if CLI path failed.
**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):**
**Historical commands (Supabase CLI path — both scripts are gone):**
```bash
# Supabase CLI path (legacy — do not use going forward)
# Supabase CLI path (legacy — script deleted, do not use)
supabase login
supabase link --project-ref wnzkhezyhnfzhkhiflrp
node supabase/push-migrations.js 148 # CLI path
node supabase/push-migrations.js 148 # CLI path (script deleted)
# or
npm run migrate:one 148
```
```bash
# Direct pg path (this is the future — only the pg branch is kept alive in the script)
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
# or
# Direct pg path (this was the future — now the only path, via scripts/migrate.js)
DATABASE_URL=postgres://... npm run migrate:one 148
```
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
`npm run migrate` (no arg) pushes every `*.sql` in `db/migrations/` in order (use with caution).
---
@@ -198,16 +211,14 @@ Verification queries (post-apply) confirmed:
---
## Current State / Gotchas (2026-06-06)
## Current State / Gotchas (2026-06)
- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only.
- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.)
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase.
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`.
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch.
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
- Use `DATABASE_URL` + `pg` directly (via `scripts/migrate.js`). The Supabase CLI and `supabase/push-migrations.js` are no longer in the repo.
- `npm run migrate` applies every pending `db/migrations/*.sql` in lexical order inside transactions; `_migrations` tracks which files are already applied.
- Many migrations are intentionally written to be re-runnable (`CREATE OR REPLACE FUNCTION`, `CREATE TABLE IF NOT EXISTS`, guarded triggers). Re-pushing a prefix is safe.
- When adding **new** migrations, use the established numeric prefix style (`NNN_descriptive_name.sql`) and place them in `db/migrations/`. No Supabase CLI command is involved.
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs (plus the per-request `app.current_brand_id` GUC used by Drizzle).
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
---
@@ -222,9 +233,9 @@ Verification queries (post-apply) confirmed:
## Unrelated / Other Changes in Tree (as of last git status)
(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, `supabase/ADMIN_CREATE_STOP_FIX.sql`, a pricing assessment doc, etc.)
(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, a pricing assessment doc, etc.)
This MEMORY.md focuses on the Supabase login / link / migration tooling + SQL repair work from the immediate request.
This MEMORY.md originally focused on the Supabase login / link / migration tooling + SQL repair work from the immediate request. The pivot section now records the completion of the Supabase → Postgres migration.
---
+35 -49
View File
@@ -1,47 +1,38 @@
# Route Commerce — Production Deployment Checklist
**Platform Version:** 1.6
**Last Updated:** 2026-05-13
**Environment:** Next.js 16 (App Router) · Supabase · Stripe · Square · Resend · FedEx
**Platform Version:** 2.0
**Last Updated:** 2026-06-25
**Environment:** Next.js 16 (App Router) · Postgres (direct via `pg`) · Neon Auth (Better Auth) · Stripe · Square · Resend · FedEx
---
## 1. Migration Order
Apply migrations in number order. All numbered `001``092` are required.
Apply migrations in number order. The full set lives in `db/migrations/`:
```bash
# Push all migrations (sequential)
npm run migrate:one 001
npm run migrate:one 002
# ... through ...
npm run migrate:one 092
# Or push all at once via Supabase CLI
supabase db push
# Push all migrations (idempotent — `_migrations` table tracks what's applied)
npm run migrate
```
**Key migrations (last 15):**
The current migration set is consolidated (the Supabase-era 001092 series was collapsed into `0001_init.sql` plus a handful of incremental files). If `db/migrations/` is empty after the Supabase → Postgres migration, see `MEMORY.md` "Direction Pivot" for the cutover notes.
**Current migrations in `db/migrations/`:**
| # | File | Purpose |
|---|------|---------|
| 078 | `wholesale_deposit_guard.sql` | Adds `amount`, `payment_method` to `wholesale_deposits` |
| 079 | `resend_analytics.sql` | Adds Resend webhook endpoint + analytics tables |
| 080 | `communication_segments.sql` | Adds `communication_segments` table |
| 081 | `stop_blast_campaign.sql` | Adds `stop_blast_campaign` RPC |
| 082 | `brand_name_in_campaign_email.sql` | Adds `brand_name` column to `communication_campaigns` |
| 083 | `shipping_settings.sql` | Adds `shipping_settings` table |
| 084 | `shipments.sql` | Adds `shipments` table for FedEx tracking |
| 085 | `brand_settings.sql` | Adds `brand_settings` table (core) |
| 086 | `brand_settings_email_integration.sql` | Adds email/webhook fields to `brand_settings` |
| 087 | `brand_logos_bucket.sql` | Adds `brand-logos` storage bucket |
| 088 | `brand_features.sql` | Adds `brand_features` table + RPCs for add-on system |
| 089 | `ai_providers_custom_integrations.sql` | Adds AI provider credentials storage |
| 090 | `storefront_settings.sql` | Adds storefront customization fields + `get_brand_settings_by_slug` RPC |
| 091 | `091_brand_plan_tier.sql` | Adds `plan_tier`, limits, `stripe_customer_id`, `get_brand_plan_info` RPC |
| 092 | `092_stripe_subscriptions.sql` | Adds `stripe_subscription_id/status/current_period_end`, `set_brand_subscription`, `get_brand_subscription` RPCs |
| 0000 | `0000_qa_neon_auth_stub.sql` | QA-only stub for `neon_auth` schema (real provisioning via Neon Auth in prod) |
| 0001 | `0001_init.sql` | Core schema: brands, products, orders, stops, customers, communication tables, water log, time tracking, RPCs, indexes, triggers. Fully re-runnable (`IF NOT EXISTS` + trigger guards). |
| 0002 | `0002_admin_password.sql` | Adds `password_hash` column to `admin_users` |
| 0003 | `0003_tour_rpc_functions.sql` | RPCs for tour/stop planning |
| 0040 | `0040_command_center.sql` | Adds command-center dashboard tables |
| 0041 | `0041_command_center_schema_fix.sql` | Schema fixup for command-center |
| 0042 | `0042_drop_command_center.sql` | Drops command-center (rolled back) |
| 0043 | `0043_admin_users_extra_columns.sql` | Adds `display_name`, `phone_number`, `brand_id`, `can_manage_pickup/messages/refunds/users`, `active`, `must_change_password`, `auth_provider`, `auth_subject`, `last_login` columns to `admin_users` |
| 0090 | `0090_water_log_completion.sql` | Completes water-log tables/RPCs |
| 0091 | `0091_dashboard_summary_rpc.sql` | `dashboard_summary` RPC for admin dashboard |
> **Note:** Migration 092 adds Stripe subscription tracking. Migration 091 adds plan tier billing. Apply both before deploying billing changes.
> **Note:** The migration runner (`scripts/migrate.js`) tracks applied files in `_migrations` and skips already-applied files. `0001_init.sql` is fully re-runnable, so it is safe to re-apply on a DB that was initialized outside the runner. Stripe subscription tracking, plan-tier billing, brand settings, etc. are all part of `0001_init.sql` — they were originally separate Supabase-era migrations that were consolidated.
---
@@ -53,10 +44,11 @@ Copy `.env.local.example` to `.env.local` and fill in all values.
| Variable | Required | Description |
|---|---|---|
| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL (e.g., `https://xxx.supabase.co`) |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anon/public key (safe to expose in browser) |
| `SUPABASE_SERVICE_ROLE_KEY` | Yes | Supabase service role key — **never expose in browser**, server-side only |
| `SUPABASE_PAT` | Yes | Supabase Personal Access Token for migrations CLI |
| `DATABASE_URL` | Yes | Postgres connection string (e.g., `postgresql://user:pass@host:5432/dbname?sslmode=require`) |
| `DATABASE_ADMIN_URL` | No | Direct (non-pooled) Postgres connection with elevated perms. Only used by `scripts/migrate.js` for DDL — falls back to `DATABASE_URL` if unset. |
| `NEON_AUTH_BASE_URL` | Yes | Neon Auth (Better Auth) base URL |
| `NEON_AUTH_COOKIE_SECRET` | Yes | Neon Auth session cookie signing secret (min 32 chars) |
| `NEXT_PUBLIC_SITE_URL` | Yes | Public site URL used for auth redirects and Origin header |
### Payments
@@ -198,17 +190,13 @@ For scheduled tasks (stop reminders, campaign sends), add to `vercel.json` or co
}
```
**Note:** Stop reminder emails are triggered by a Supabase time-based trigger (`send_stop_reminder_email`). Verify the trigger is active:
```sql
SELECT * FROM pg_publication_tables WHERE tablename = 'stops';
```
If not, recreate: `ALTER PUBLICATION supabase_realtime PUBLISH INSERT, UPDATE ON TABLE stops;`
**Note:** Stop reminder emails are fired by Vercel cron jobs (`/api/cron/send-stop-reminders`), not by a database trigger. Verify the cron is configured in `vercel.json` (or Vercel Dashboard → Project → Cron Jobs).
---
## 5. Feature Flag Enablement (Post-Deploy)
After deployment, enable add-ons per brand via the admin UI or Supabase SQL:
After deployment, enable add-ons per brand via the admin UI or direct Postgres:
```sql
-- Enable Harvest Reach for a brand
@@ -302,13 +290,13 @@ Vercel keeps 10 recent deployments. For critical regressions, promote the previo
### Database Rollback
```bash
# Revert last migration via Supabase
supabase db reset --backup-id <backup-before-migration>
# Revert last migration via Postgres
# (Restore from a `pg_dump` snapshot taken before the migration was applied)
# Or manually revert (example for migration 090)
psql $DATABASE_URL -c "ALTER TABLE brand_settings DROP COLUMN IF EXISTS schedule_pdf_notes;"
```
> **Important:** Only use `supabase db reset` on staging. In production, manually revert individual migrations to avoid data loss.
> **Important:** Always restore from a known-good backup. In production, manually revert individual migrations to avoid data loss.
### Environment Variable Rollback
Environment variables are version-controlled in Vercel. Restore a previous version in **Vercel Dashboard → Project → Environment Variables → History**.
@@ -318,16 +306,16 @@ Environment variables are version-controlled in Vercel. Restore a previous versi
## 8. Known Limitations & Future Work
### Known Issues
- **`dev_session` auth**: The `dev_session` cookie bypasses real Supabase auth. In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`.
- **`dev_session` auth**: The `dev_session` cookie bypasses real Neon Auth (Better Auth). In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`.
- **Water Log visible to all brand admins**: Currently Tuxedo-only by brand ID check. If another brand admin needs access, the check needs to be made configurable.
- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have RLS disabled. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct Supabase queries.
- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have no row-level policies. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct SQL from client-side code.
- **`event_id` not populated in message logs**: `send_campaign` and `send_stop_blast` do not set `event_id`. The Resend webhook looks up logs by `customer_email + subject` instead. This is a known limitation — if Resend events are delayed, the lookup may fail to match.
- **Square sync is one-directional**: Products import from Square → Route Commerce, but product edits in Route Commerce do not push back to Square.
- **SMS opt-in defaults to `FALSE`**: `communication_contacts.sms_opt_in` defaults to `false`. Users must explicitly opt in for SMS campaigns.
- **`brand_id: null` for platform admins**: `getAdminUser()` returns `brand_id: null` for `platform_admin` role. Always pass explicit `brandId` to server action functions.
- **Square webhook signature mismatch**: The Square webhook handler uses `crypto.createHmac("sha256", signatureKey)` but Square sends the signature as base64-encoded. Verify signature encoding matches Square's documentation before enabling Square webhooks in production.
- **Stripe webhook reliability**: Stripe webhook delivery is not guaranteed to be in-order or immediate. The webhook handler is idempotent — it can safely receive duplicate events. Consider implementing a webhook event log table to track processed event IDs for deduplication.
- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct Supabase queries to these tables in client-side code.
- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct SQL to these tables in client-side code.
### Future Work (Non-Blocking)
- **Cursor-based pagination**: Replace offset-based pagination (`page * limit`) with cursor-based pagination for large datasets (orders, contacts)
@@ -344,11 +332,9 @@ Environment variables are version-controlled in Vercel. Restore a previous versi
| Role | Contact |
|------|---------|
| Platform Admin | Set via Supabase Auth dashboard |
| Platform Admin | First Neon Auth user becomes the platform admin via `provision-admin.ts` |
| Kyle Martinez | Primary contact — Route Commerce owner |
| Supabase Support | supabase.com/support |
| Neon Support | neon.tech/support |
| Vercel Support | vercel.com/support |
For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button.
For Supabase production incidents: **supabase.com/dashboard** → project → "Support" tab.
+14 -14
View File
@@ -15,16 +15,16 @@ npm run dev
## Configuration
### 1. Clerk Authentication
### 1. Neon Auth (Better Auth)
1. Sign up at [Clerk](https://clerk.com)
2. Create a new application
3. Copy keys to `.env.local`:
1. Set up Neon Auth on your Neon project (`neonctl neon-auth init`)
2. Copy keys to `.env.local`:
```
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx
CLERK_SECRET_KEY=sk_test_xxx
NEON_AUTH_BASE_URL=https://xxx.neonauth.io
NEON_AUTH_COOKIE_SECRET=<openssl rand -base64 32>
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
```
4. Configure middleware in `src/proxy.ts`
3. Configure middleware in `src/proxy.ts`
### 2. Stripe Payments
@@ -37,15 +37,15 @@ npm run dev
STRIPE_WEBHOOK_SECRET=whsec_xxx
```
### 3. Supabase Database
### 3. Database (Postgres / Neon)
1. Create project at [Supabase](https://supabase.com)
The app talks to Postgres directly via `pg` (no Supabase, no REST gateway). Point `DATABASE_URL` at any reachable Postgres — Neon, Supabase (the underlying Postgres), RDS, etc. The Supabase JS client and PostgREST are not used.
1. Provision a Postgres database (Neon recommended — it also hosts Neon Auth)
2. Run migrations: `npm run migrate`
3. Update `.env.local` with:
```
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx
SUPABASE_SERVICE_ROLE_KEY=xxx
DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
```
### 4. Optional Services
@@ -74,7 +74,7 @@ Ensure environment variables are set before deployment.
## Key Files
- `src/proxy.ts` - Clerk middleware
- `src/proxy.ts` - Neon Auth (Better Auth) middleware
- `src/lib/stripe-billing.ts` - Stripe integration
- `src/lib/analytics.ts` - PostHog analytics
- `src/lib/sentry.ts` - Sentry error tracking
@@ -82,7 +82,7 @@ Ensure environment variables are set before deployment.
- `src/components/onboarding/OnboardingFlow.tsx` - User onboarding
- `src/components/referral/ReferralSystem.tsx` - Referral tracking
- `src/components/changelog/ChangelogFeed.tsx` - Product updates
- `supabase/migrations/` - Database schema
- `db/migrations/` - Database schema (applied via `scripts/migrate.js`)
## Scripts
+10 -13
View File
@@ -17,7 +17,7 @@ Route Commerce helps produce brands run their wholesale operations:
## Tech Stack
- **Framework:** Next.js 16 (App Router)
- **Database:** Supabase (Postgres + Auth + RLS)
- **Database:** Postgres (direct via `pg` + Drizzle) with Neon Auth (Better Auth)
- **Payments:** Stripe + Square
- **Email/SMS:** Resend
- **Styling:** Tailwind CSS v4
@@ -37,10 +37,8 @@ npm install
Create a `.env.local` file with:
```bash
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Database (Postgres)
DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
# Stripe
STRIPE_SECRET_KEY=sk_test_...
@@ -56,10 +54,9 @@ RESEND_API_KEY=re_...
### 3. Set up the database
Link your Supabase project, then push migrations:
The migrations live in `db/migrations/` and are applied via the `pg` driver (no Supabase CLI):
```bash
supabase link --project-ref <your-project-ref>
npm run migrate
```
@@ -75,7 +72,7 @@ npm run migrate:one 83
npm run dev
```
Open [http://localhost:3000](http://localhost:3000). The dev server auto-runs `fix-agents.js` to patch Next.js App Router agent issues.
Open [http://localhost:4000](http://localhost:4000). The dev server auto-runs `fix-agents.js` to patch Next.js App Router agent issues. (The dev script binds to `0.0.0.0:4000`; override with `npm run dev -- -p <port>` if you need a different port.)
### 5. Dev auth bypass
@@ -111,7 +108,7 @@ src/
│ ├── admin/ # Admin-specific components
│ └── storefront/ # Storefront components
└── lib/ # Utilities, auth, feature flags, formatting
supabase/
db/
└── migrations/ # SQL migrations (numbered sequentially)
```
@@ -171,7 +168,7 @@ The admin dashboard lives at `/admin`:
- **Communications** — Harvest Reach campaign manager
- **Wholesale** — Wholesale portal settings
- **Billing** — Plan and subscription management
- **Water Log** — Irrigation tracking (add-on)
- **Water Log** — Irrigation tracking (add-on) — see [docs/water-log.md](docs/water-log.md)
- **Settings** — Brand settings, payments, apps
## Email Automations (Harvest Reach)
@@ -253,7 +250,7 @@ CRON_SECRET=... # Bearer token to secure cron endpoints (generate
```bash
# Run abandoned cart with verbose output
curl -X POST http://localhost:3000/api/email-automation/abandoned-cart \
curl -X POST http://localhost:4000/api/email-automation/abandoned-cart \
-H "Authorization: Bearer local-dev-secret" \
-H "Content-Type: application/json"
@@ -315,8 +312,8 @@ curl -X POST https://route-commerce-platform.vercel.app/api/email-automation/wel
## Notes
- All database writes go through **server actions** (`src/actions/`), not the Supabase JS client directly
- Dev mode bypasses Supabase auth via `dev_session` cookie — never use this in production
- All database writes go through **server actions** (`src/actions/`) over a shared `pg` `Pool` from `src/lib/db.ts` — no Supabase JS client, no REST gateway
- Dev mode bypasses Neon Auth via `dev_session` cookie — never use this in production
- Brand-scoped data is enforced at the application layer via `p_brand_id` parameters in SECURITY DEFINER RPCs
- Display dates use `formatDate()` (MM/DD/YYYY) — never raw `toLocaleDateString()`
- Migration files are numbered sequentially — never reuse numbers
+12 -1
View File
@@ -4,6 +4,8 @@
**Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out
all Supabase references from the auth/admin path.
> **Note (2026-06-25):** Auth has since moved from Auth.js (NextAuth v5) to **Neon Auth (Better Auth)** — see `CLAUDE.md` for the current architecture. The Supabase purge described below is complete: `supabase/` directory deleted, `@supabase/*` deps removed from `package.json`, `next.config.ts` / `vercel.json` / `eslint.config.mjs` cleaned of Supabase hostnames and ignore patterns, and the remaining `*.supabase.co` references are limited to historical/educational comments in `MEMORY.md` and `docs/`.
---
## What was built
@@ -171,7 +173,7 @@ src/actions/ai/preferences.ts
1. **Provision a `pg` pool in production.** `DATABASE_URL` must be
set in the hosting dashboard (same env var as
`supabase/push-migrations.js` uses).
`scripts/migrate.js` uses).
2. **Apply migration 204** if not already applied — adds
`email`, `auth_provider`, `auth_subject` columns to `admin_users`
and the `get_admin_user_for_session` RPC.
@@ -192,6 +194,15 @@ src/actions/ai/preferences.ts
7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from
`package.json`** (last step once no file imports them).
### Resolution (2026-06-25)
Follow-ups 6 and 7 (and the data-fetching migration of all 33 files) are
complete. The codebase is now Supabase-free at the source-code level —
`grep -rln '@supabase\|rest/v1' src/` returns zero matches. The only
remaining mentions of "Supabase" are in this report, `MEMORY.md`, and a
handful of historical `docs/superpowers/{plans,specs}/` files, all kept
as historical record of the migration.
---
## Commands
+12 -49
View File
@@ -23,38 +23,22 @@
*/
import "server-only";
import { Pool, type PoolClient } from "pg";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import * as schema from "./schema";
import { getPool, withTx } from "@/lib/db";
type Schema = typeof schema;
export type Db = NodePgDatabase<Schema>;
let _pool: Pool | null = null;
function getPool(): Pool {
if (_pool) return _pool;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error(
"DATABASE_URL is not set. Add it to .env.local (see .env.example).",
);
}
_pool = new Pool({
connectionString,
max: parseInt(process.env.PG_POOL_MAX ?? "10", 10),
idleTimeoutMillis: parseInt(process.env.PG_POOL_IDLE_MS ?? "30000", 10),
connectionTimeoutMillis: parseInt(
process.env.PG_POOL_CONN_TIMEOUT_MS ?? "10000",
10,
),
allowExitOnIdle: false,
});
_pool.on("error", (err) => {
console.error("[db] idle client error", err);
});
return _pool;
}
/**
* The Drizzle layer reuses the shared `pg` `Pool` from `@/lib/db` so the
* app connects to Postgres through a single pool. Connection settings
* (`PG_POOL_MAX`, `PG_POOL_IDLE_MS`, `PG_POOL_CONN_TIMEOUT_MS`,
* `DATABASE_URL`) live in one place. See `src/lib/db.ts` for the config.
*
* The `withBrand` and `withPlatformAdmin` helpers reuse `withTx` from
* `@/lib/db` so the BEGIN/COMMIT/ROLLBACK handling has a single home.
*/
/**
* Run `fn` with a Drizzle client. No brand context is set — the caller
@@ -83,7 +67,7 @@ export async function withBrand<T>(
brandId: string,
fn: (db: Db) => Promise<T>,
): Promise<T> {
return runInTransaction(async (client) => {
return withTx(async (client) => {
// set_config(setting, value, is_local) — is_local=true makes it
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never
// leaks across pooled connections.
@@ -103,7 +87,7 @@ export async function withBrand<T>(
export async function withPlatformAdmin<T>(
fn: (db: Db) => Promise<T>,
): Promise<T> {
return runInTransaction(async (client) => {
return withTx(async (client) => {
await client.query("SELECT set_config('app.current_brand_id', '', true)");
await client.query("SELECT set_config('app.platform_admin', 'true', true)");
const db = drizzle(client, { schema });
@@ -111,25 +95,4 @@ 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 };
+71
View File
@@ -0,0 +1,71 @@
-- Migration 003: Batch insert RPCs for tour stop / location seeding
-- Required by db/seeds/2026-tuxedo-tour-stops.sql and scripts/import-tuxedo-stops.ts
BEGIN;
-- admin_create_locations_batch: insert or update locations from tour seed
-- Payload shape: { name, address, city, state, zip, phone, contact_name, contact_email, notes, active }[]
CREATE OR REPLACE FUNCTION admin_create_locations_batch(p_brand_id UUID, p_locations JSONB)
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_loc JSONB;
BEGIN
FOR v_loc IN SELECT * FROM jsonb_array_elements(p_locations)
LOOP
INSERT INTO locations (
brand_id, name, address, city, state, zip,
phone, contact_name, contact_email, notes, active
) VALUES (
p_brand_id,
v_loc->>'name',
v_loc->>'address',
v_loc->>'city',
v_loc->>'state',
NULLIF(v_loc->>'zip', '')::TEXT,
v_loc->>'phone',
v_loc->>'contact_name',
v_loc->>'contact_email',
v_loc->>'notes',
COALESCE((v_loc->>'active')::BOOLEAN, true)
)
ON CONFLICT DO NOTHING;
END LOOP;
END;
$$;
-- admin_create_stops_batch: insert stops from tour seed
-- Payload shape: { city, state, location, date, time, address, zip, cutoff_time, active, notes }[]
-- date format: '2026-07-22 00:00:00+00' — cast to DATE
CREATE OR REPLACE FUNCTION admin_create_stops_batch(p_brand_id UUID, p_stops JSONB)
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_stop JSONB;
BEGIN
FOR v_stop IN SELECT * FROM jsonb_array_elements(p_stops)
LOOP
INSERT INTO stops (
brand_id, name, location, address, city, state, zip,
date, "time", cutoff_date, status, is_public, notes
) VALUES (
p_brand_id,
COALESCE(NULLIF(v_stop->>'name', ''), (v_stop->>'location')::TEXT || ' - ' || (v_stop->>'city')::TEXT || ', ' || (v_stop->>'state')::TEXT),
v_stop->>'location',
v_stop->>'address',
v_stop->>'city',
v_stop->>'state',
NULLIF(v_stop->>'zip', '')::TEXT,
CASE
WHEN v_stop->>'date' IS NULL THEN NULL
ELSE LEFT(v_stop->>'date', 10)::DATE
END,
v_stop->>'time',
NULLIF(v_stop->>'cutoff_time', '')::DATE,
'active',
COALESCE((v_stop->>'active')::BOOLEAN, true),
v_stop->>'notes'
);
END LOOP;
END;
$$;
COMMIT;
@@ -1,6 +1,48 @@
-- Migration 127: Platform command center RPCs (final applied version)
-- Migration 0040: Platform command center — founder_pain_log table/view + RPCs
-- Restores objects that the /admin/command-center page depends on.
-- Original definitions were in archived Supabase migrations 126 + 127.
-- Platform-wide metrics
-- ── founder_pain_log table ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS founder_pain_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
severity TEXT NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
category TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ,
resolved_by UUID REFERENCES admin_users(id) ON DELETE SET NULL
);
-- ── founder_pain_log_platform view (joins brand name/slug for the UI) ────────
CREATE OR REPLACE VIEW founder_pain_log_platform AS
SELECT
fpl.id,
fpl.brand_id,
fpl.severity,
fpl.category,
fpl.title,
fpl.description,
fpl.status,
fpl.created_at,
fpl.resolved_at,
fpl.resolved_by,
b.name AS brand_name,
b.slug AS brand_slug
FROM founder_pain_log fpl
LEFT JOIN brands b ON b.id = fpl.brand_id
ORDER BY
CASE fpl.severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
END,
fpl.created_at DESC;
-- ── Platform-wide metrics ────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION get_platform_command_center_metrics()
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER
@@ -47,7 +89,7 @@ BEGIN
END;
$$;
-- Recent activity feed (last 50 operational events across all brands)
-- ── Recent activity feed (last 50 operational events across all brands) ──────
CREATE OR REPLACE FUNCTION get_platform_activity_feed()
RETURNS TABLE (
id UUID,
@@ -83,8 +125,7 @@ BEGIN
END;
$$;
-- Per-brand health snapshot
DROP FUNCTION IF EXISTS get_brand_health_snapshot();
-- ── Per-brand health snapshot ────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION get_brand_health_snapshot()
RETURNS TABLE (
brand_id UUID,
@@ -0,0 +1,147 @@
-- Migration 0041: Fix command-center RPCs against actual prod schema
--
-- Migration 0040 was written against an assumed schema and references columns
-- that don't exist in the prod `orders` table:
-- - `orders.created_at` does not exist → use `orders.placed_at`
-- - `orders.subtotal` does not exist → use `orders.total_cents / 100`
-- - `stops.date` is already DATE, not TEXT → drop the `~ '^\d{4}-...' ` regex
--
-- All three functions are fixed in place via CREATE OR REPLACE FUNCTION.
-- The action layer (src/actions/platform/command-center.ts) is updated
-- separately to use `SELECT * FROM fn()` for the two setof-returning functions.
--
-- Reference (prod schema as of 2026-06-17):
-- orders: id, brand_id, customer_id, stop_id, total_cents INT,
-- status TEXT, fulfillment TEXT, customer_*,
-- idempotency_key, notes, placed_at TIMESTAMPTZ, updated_at
-- stops: id, brand_id, name, location, address, city, state, zip,
-- date DATE, time, cutoff_date, status, is_public, notes,
-- created_at, updated_at
-- operational_events: id, brand_id, event_type, entity_type, entity_id,
-- actor_type, actor_id, source, payload JSONB, created_at
-- brands: (assumed present per CLAUDE.md)
-- ── Platform-wide metrics ──────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION get_platform_command_center_metrics()
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
result JSONB;
BEGIN
result := jsonb_build_object(
'active_brands', (
SELECT COUNT(*) FROM brands WHERE slug IN ('tuxedo', 'indian-river-direct')
),
'orders_today', (
SELECT COUNT(*) FROM orders
WHERE DATE(placed_at) = CURRENT_DATE
),
'revenue_today', (
SELECT COALESCE(SUM(total_cents), 0)::NUMERIC / 100
FROM orders
WHERE DATE(placed_at) = CURRENT_DATE
AND status NOT IN ('cancelled', 'refunded')
),
'active_routes', (
SELECT COUNT(DISTINCT s.id)
FROM stops s
WHERE s.date >= CURRENT_DATE
AND s.status = 'active'
),
'failed_orders_today', (
SELECT COUNT(*)
FROM orders
WHERE DATE(placed_at) = CURRENT_DATE
AND status IN ('payment_failed', 'failed')
),
'pending_orders_today', (
SELECT COUNT(*)
FROM orders
WHERE DATE(placed_at) = CURRENT_DATE
AND status = 'pending'
)
);
RETURN result;
END;
$$;
-- ── Per-brand health snapshot ──────────────────────────────────────────────
CREATE OR REPLACE FUNCTION get_brand_health_snapshot()
RETURNS TABLE (
brand_id UUID,
brand_name TEXT,
brand_slug TEXT,
plan_tier TEXT,
orders_today INT,
revenue_today NUMERIC,
active_stops INT,
failed_orders INT,
pending_orders INT,
last_activity TIMESTAMPTZ,
open_pain_items INT,
health_status TEXT
)
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
RETURN QUERY
WITH bm AS (
SELECT
b.id AS bid,
b.name AS bname,
b.slug AS bslug,
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE) AS ords_today,
(COALESCE(SUM(o.total_cents) FILTER (
WHERE DATE(o.placed_at) = CURRENT_DATE
AND o.status NOT IN ('cancelled', 'refunded')
), 0)::NUMERIC / 100) AS rev_today,
COUNT(DISTINCT s.id) FILTER (
WHERE s.date >= CURRENT_DATE AND s.status = 'active'
) AS act_stops,
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE AND o.status IN ('payment_failed', 'failed')) AS fail_ords,
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE AND o.status = 'pending') AS pend_ords,
MAX(oe.created_at) AS last_act
FROM brands b
LEFT JOIN orders o ON o.brand_id = b.id
LEFT JOIN stops s ON s.brand_id = b.id
LEFT JOIN operational_events oe
ON oe.payload->>'brand_id' = b.id::TEXT
AND oe.payload->>'brand_id' ~ '^[0-9a-fA-F-]{36}$'
WHERE b.slug IN ('tuxedo', 'indian-river-direct')
GROUP BY b.id, b.name, b.slug
),
pc AS (
SELECT f.brand_id AS pc_bid, COUNT(*) AS open_cnt
FROM founder_pain_log f
WHERE f.status = 'open'
GROUP BY f.brand_id
)
SELECT
bm.bid,
bm.bname,
bm.bslug,
NULL::TEXT,
COALESCE(bm.ords_today, 0)::INT,
COALESCE(bm.rev_today, 0)::NUMERIC,
COALESCE(bm.act_stops, 0)::INT,
COALESCE(bm.fail_ords, 0)::INT,
COALESCE(bm.pend_ords, 0)::INT,
bm.last_act,
COALESCE(pc.open_cnt, 0)::INT,
CASE
WHEN COALESCE(bm.fail_ords, 0) > 0 THEN 'critical'
WHEN COALESCE(bm.pend_ords, 0) > 5 THEN 'warning'
WHEN COALESCE(pc.open_cnt, 0) > 0 THEN 'warning'
WHEN bm.last_act IS NULL OR bm.last_act < (NOW() - INTERVAL '24 hours') THEN 'warning'
ELSE 'healthy'
END
FROM bm
LEFT JOIN pc ON pc.pc_bid = bm.bid;
END;
$$;
-- get_platform_activity_feed from 0040 is already correct against the prod
-- schema (operational_events.payload->>'brand_id' is a text→UUID cast that
-- nulls out on missing keys). No body change required.
@@ -0,0 +1,19 @@
-- Migration 0042: Drop command-center RPCs and founder_pain_log
--
-- The /admin/command-center page is being removed (see commit message).
-- All objects it depended on are also dropped:
-- - get_platform_command_center_metrics (JSONB-returning RPC)
-- - get_platform_activity_feed (TABLE-returning RPC)
-- - get_brand_health_snapshot (TABLE-returning RPC)
-- - founder_pain_log (table)
-- - founder_pain_log_platform (view, joins brand name onto founder_pain_log)
--
-- The platform_admin role has no other consumer of these objects. The
-- cross-brand KPIs and activity feed that the page showed are reachable
-- per-brand via /admin, /admin/orders, /admin/stops, and /admin/reports.
DROP VIEW IF EXISTS founder_pain_log_platform;
DROP FUNCTION IF EXISTS get_brand_health_snapshot();
DROP FUNCTION IF EXISTS get_platform_activity_feed();
DROP FUNCTION IF EXISTS get_platform_command_center_metrics();
DROP TABLE IF EXISTS founder_pain_log;
@@ -0,0 +1,70 @@
-- 0043_admin_users_extra_columns.sql
--
-- The application-layer admin user CRUD in `src/actions/admin/users.ts`
-- was written for a richer `admin_users` schema than the one that
-- landed in 0001_init.sql. The 0001 schema has just `name` plus the
-- role-derived flag columns (`can_manage_orders`, `can_manage_products`,
-- `can_manage_stops`, `can_manage_customers`, `can_manage_wholesale`,
-- `can_manage_billing`, `can_manage_settings`, `can_manage_water_log`,
-- `can_manage_time_tracking`, `can_manage_route_trace`,
-- `can_manage_reports`, `can_manage_communications`).
--
-- The action also references these columns, which are not in 0001:
--
-- display_name, phone_number, brand_id,
-- can_manage_pickup, can_manage_messages,
-- can_manage_refunds, can_manage_users,
-- active, must_change_password,
-- auth_provider, auth_subject, last_login
--
-- This migration adds all of them so the create-user, list-users,
-- update-user, and read-user flows work against the actual table.
-- Re-runnable: each ALTER uses ADD COLUMN IF NOT EXISTS.
--
-- Notes on the new columns:
--
-- - `display_name` is what the UI shows in the user list / "logged in
-- as" labels. The original `name` column from 0001 is left in place
-- (Drizzle `adminUsers.name` still maps to it; `getAdminUser()` reads
-- it) — `display_name` is the column the action's SQL touches so it
-- stays the writable surface for the create-user form. Both can
-- coexist; the next cleanup pass can collapse them.
--
-- - The four extra `can_manage_*` flags (pickup / messages / refunds /
-- users) are not consulted by `getAdminUser()` — that lookup uses
-- `permissionsForRole(role)` from `lib/admin-permissions.ts`, which
-- derives flags from the user's role rather than from these
-- columns. They are kept on the row so the create-user form's
-- per-user permission toggles can persist; they simply do not yet
-- affect runtime authorization. Cleanup target: either move them into
-- a per-user permission table that the role-derived lookup merges
-- in, or drop them from the form.
--
-- - `brand_id` is a denormalization of `admin_user_brands` (the link
-- table is the source of truth for brand assignment, and
-- `getAdminUser()` reads from it). The action writes both, which is
-- redundant but not incorrect — keeping it makes the action's
-- read-after-write (`getAdminUsers` joins on `au.brand_id`) work
-- without rewriting the SELECT.
--
-- - `must_change_password` defaults to FALSE at the column level; the
-- action sets it to TRUE on every create so the user is forced to
-- set a new password on first sign-in.
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS display_name TEXT,
ADD COLUMN IF NOT EXISTS phone_number TEXT,
ADD COLUMN IF NOT EXISTS brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS can_manage_pickup BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS can_manage_messages BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS can_manage_refunds BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS can_manage_users BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS auth_provider TEXT,
ADD COLUMN IF NOT EXISTS auth_subject TEXT,
ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ;
-- Helpful index for the brand-name lookup the user list does
-- (LEFT JOIN brands b ON b.id = au.brand_id).
CREATE INDEX IF NOT EXISTS admin_users_brand_id_idx ON admin_users (brand_id);
+172
View File
@@ -0,0 +1,172 @@
-- 0090_water_log_completion.sql
--
-- Water Log feature completion. The original `0001_init.sql` shipped the
-- core five tables (headgates, irrigators, sessions, log entries, alerts)
-- with RLS, but the SaaS rebuild's actions and UIs depend on a wider
-- surface that this migration adds:
--
-- - `water_headgates.headgate_token` — opaque per-gate token for QR
-- - `water_headgates.status` — open / closed / maintenance
-- - `water_headgates.max_flow_gpm` — optional high-water marker
-- - `water_headgates.notes` — free-form location notes
-- - `water_headgates.last_used_at` — denormalized for display
-- - `water_headgates.high_threshold` — optional alert ceiling
-- - `water_headgates.low_threshold` — optional alert floor
-- - `water_headgates.unit` — display unit (CFS/GPM/etc.)
--
-- - `water_irrigators.role` — "irrigator" | "water_admin"
-- - `water_irrigators.phone` — optional contact
-- - `water_irrigators.notes` — free-form
--
-- - `water_log_entries.method` — "manual" | "meter" | "estimate" | "qr"
-- - `water_log_entries.total_gallons` — derived when computable
-- - `water_log_entries.photo_url` — link to storage object
-- - `water_log_entries.logged_date` — date-only (YYYY-MM-DD) for fast grouping
-- - `water_log_entries.latitude`/`longitude` — optional GPS pin
-- - `water_log_entries.brand_id` index upgrade
--
-- - NEW `water_admin_settings` (per-brand admin PIN + alert config)
-- - NEW `water_admin_sessions` (admin sign-in sessions, separate cookie)
-- - NEW `water_audit_log` (who changed what, when)
--
-- All new tables follow project conventions:
-- - TIMESTAMPTZ for timestamps
-- - UUID PKs via gen_random_uuid()
-- - brand_id scoped, RLS enabled, FORCE'd
-- - CREATE TABLE IF NOT EXISTS for re-runnability
--
-- ============================================================================
-- ─── 1. Extend water_headgates ──────────────────────────────────────────────
ALTER TABLE water_headgates
ADD COLUMN IF NOT EXISTS headgate_token TEXT UNIQUE
DEFAULT encode(gen_random_bytes(12), 'hex'),
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'closed', 'maintenance')),
ADD COLUMN IF NOT EXISTS max_flow_gpm NUMERIC,
ADD COLUMN IF NOT EXISTS notes TEXT,
ADD COLUMN IF NOT EXISTS last_used_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS high_threshold NUMERIC,
ADD COLUMN IF NOT EXISTS low_threshold NUMERIC,
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS';
-- Backfill any existing rows that pre-date the default expression
UPDATE water_headgates
SET headgate_token = encode(gen_random_bytes(12), 'hex')
WHERE headgate_token IS NULL;
-- ─── 2. Extend water_irrigators ─────────────────────────────────────────────
ALTER TABLE water_irrigators
ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'irrigator'
CHECK (role IN ('irrigator', 'water_admin')),
ADD COLUMN IF NOT EXISTS phone TEXT,
ADD COLUMN IF NOT EXISTS notes TEXT;
-- ─── 3. Extend water_log_entries ────────────────────────────────────────────
ALTER TABLE water_log_entries
ADD COLUMN IF NOT EXISTS method TEXT NOT NULL DEFAULT 'manual'
CHECK (method IN ('manual', 'meter', 'estimate', 'qr')),
ADD COLUMN IF NOT EXISTS total_gallons NUMERIC,
ADD COLUMN IF NOT EXISTS photo_url TEXT,
ADD COLUMN IF NOT EXISTS logged_date DATE,
ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION;
-- Backfill logged_date for any rows that pre-date the column
UPDATE water_log_entries
SET logged_date = (logged_at AT TIME ZONE 'UTC')::date
WHERE logged_date IS NULL AND logged_at IS NOT NULL;
-- Index for fast "today's entries" + "this week's entries" queries
CREATE INDEX IF NOT EXISTS water_log_entries_brand_date_idx
ON water_log_entries (brand_id, logged_date DESC);
CREATE INDEX IF NOT EXISTS water_log_entries_irrigator_idx
ON water_log_entries (irrigator_id, logged_at DESC);
-- ─── 4. water_admin_settings (per brand) ───────────────────────────────────
CREATE TABLE IF NOT EXISTS water_admin_settings (
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT true,
session_duration_hours INTEGER NOT NULL DEFAULT 4
CHECK (session_duration_hours BETWEEN 1 AND 168),
can_edit_entries BOOLEAN NOT NULL DEFAULT true,
can_delete_entries BOOLEAN NOT NULL DEFAULT true,
can_export_csv BOOLEAN NOT NULL DEFAULT true,
alert_phone TEXT,
alerts_enabled BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by UUID REFERENCES admin_users(id)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'water_admin_settings_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER water_admin_settings_updated_at BEFORE UPDATE ON water_admin_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
-- ─── 5. water_admin_sessions (separate from irrigator sessions) ───────────
CREATE TABLE IF NOT EXISTS water_admin_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
pin_hash_used TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS water_admin_sessions_admin_idx
ON water_admin_sessions (admin_user_id, expires_at DESC);
-- ─── 6. water_audit_log (who changed what, when) ───────────────────────────
CREATE TABLE IF NOT EXISTS water_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
actor_id UUID REFERENCES admin_users(id),
actor_label TEXT NOT NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID,
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS water_audit_log_brand_recent_idx
ON water_audit_log (brand_id, created_at DESC);
-- ─── 7. RLS for new tables ────────────────────────────────────────────────
ALTER TABLE water_admin_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_admin_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_audit_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_admin_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE water_admin_sessions FORCE ROW LEVEL SECURITY;
ALTER TABLE water_audit_log FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON water_admin_settings;
CREATE POLICY tenant_isolation ON water_admin_settings FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON water_admin_sessions;
CREATE POLICY tenant_isolation ON water_admin_sessions FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON water_audit_log;
CREATE POLICY tenant_isolation ON water_audit_log FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
@@ -0,0 +1,89 @@
-- 0091_dashboard_summary_rpc.sql
--
-- Dashboard summary RPC for the mobile-first admin v2 dashboard
-- (`/admin/v2`).
--
-- Returns a single JSONB document that powers the four stat cards +
-- the "Today's stops" timeline + the 7-day mini-chart:
--
-- {
-- "orders_today": <int>, -- count of orders placed today
-- "revenue_today": <int cents>, -- sum of total_cents (excl. canceled)
-- "pending_fulfillment": <int>, -- count of orders awaiting pickup/ship
-- "stops_today": <int>, -- count of stops scheduled for today
-- "orders_last_7_days": [{date, count}] -- 7-element array, oldest → today
-- }
--
-- Schema notes (vs. the plan's draft):
-- * `orders` uses `placed_at` (TIMESTAMPTZ) — not `created_at`
-- * `orders.status` is the legacy enum: 'pending' | 'confirmed' |
-- 'fulfilled' | 'canceled' (not the v2 vocabulary 'placed'/'ready')
-- * `orders.total_cents` is INTEGER (NOT NULL DEFAULT 0 in 0001) — no
-- column-add step is required
-- * `stops` has a `date DATE` column (not `scheduled_at`); the legacy
-- stops page (and v2 stops page) both key off `stops.date`
--
-- SECURITY DEFINER so the v2 server action can call it without depending
-- on RLS; brand scoping is enforced inside the function via the
-- `p_brand_id` parameter (NULL → platform_admin "all brands" scope).
-- Re-runs are safe — this is `CREATE OR REPLACE FUNCTION`.
CREATE OR REPLACE FUNCTION get_dashboard_summary(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
result JSONB;
brand_filter TEXT;
BEGIN
-- The brand filter is the same text fragment for every SELECT in this
-- function. NULL = "all brands" (platform_admin scope); non-NULL =
-- scope to a single brand.
IF p_brand_id IS NULL THEN
brand_filter := '';
ELSE
brand_filter := format(' AND brand_id = %L', p_brand_id);
END IF;
EXECUTE format(
$q$
SELECT jsonb_build_object(
'orders_today', (
SELECT COUNT(*) FROM orders
WHERE placed_at::date = CURRENT_DATE %s
),
'revenue_today', (
SELECT COALESCE(SUM(total_cents), 0)::bigint FROM orders
WHERE placed_at::date = CURRENT_DATE
AND status <> 'canceled' %s
),
'pending_fulfillment', (
SELECT COUNT(*) FROM orders
WHERE status IN ('pending', 'confirmed') %s
),
'stops_today', (
SELECT COUNT(*) FROM stops
WHERE "date" = CURRENT_DATE %s
),
'orders_last_7_days', (
SELECT COALESCE(jsonb_agg(jsonb_build_object('date', d::date, 'count', COALESCE(o.cnt, 0)) ORDER BY d), '[]'::jsonb)
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') d
LEFT JOIN (
SELECT placed_at::date AS day, COUNT(*) AS cnt
FROM orders
WHERE placed_at >= CURRENT_DATE - INTERVAL '7 days' %s
GROUP BY 1
) o ON o.day = d::date
)
)
$q$,
brand_filter, brand_filter, brand_filter, brand_filter, brand_filter
) INTO result;
RETURN result;
END;
$$;
COMMENT ON FUNCTION get_dashboard_summary(UUID) IS
'Returns a single JSONB document with the four dashboard stat counts + 7-day order history. p_brand_id = NULL means platform_admin scope (all brands).';
+17
View File
@@ -60,6 +60,23 @@ export const adminUsers = pgTable(
userId: uuid("user_id"),
email: text("email").notNull().unique(),
name: text("name"),
// The columns below were added in migration 0043. They back the
// create-user / list-users / edit-user flows in
// `src/actions/admin/users.ts`. See that migration's header for
// notes on which of these are vestigial (the four can_manage_*
// toggles) vs. read by the UI.
displayName: text("display_name"),
phoneNumber: text("phone_number"),
brandId: uuid("brand_id"),
canManagePickup: boolean("can_manage_pickup").notNull().default(true),
canManageMessages: boolean("can_manage_messages").notNull().default(true),
canManageRefunds: boolean("can_manage_refunds").notNull().default(false),
canManageUsers: boolean("can_manage_users").notNull().default(false),
active: boolean("active").notNull().default(true),
mustChangePassword: boolean("must_change_password").notNull().default(false),
authProvider: text("auth_provider"),
authSubject: text("auth_subject"),
lastLogin: timestamp("last_login", { withTimezone: true }),
role: text("role", {
enum: ["platform_admin", "brand_admin", "store_employee"],
}).notNull().default("brand_admin"),
+144 -2
View File
@@ -1,5 +1,15 @@
/**
* Water Log. Source: `db/migrations/0001_init.sql`.
* Water Log. Source: `db/migrations/0001_init.sql` + `0090_water_log_completion.sql`.
*
* Six tables, all brand-scoped with RLS:
* - water_headgates — physical gates a measurement is tied to
* - water_irrigators — PIN-authenticated field workers
* - water_sessions — short-lived PIN sessions for irrigators
* - water_log_entries — the actual reading logs
* - water_alert_log — high/low threshold alert history
* - water_admin_settings — per-brand admin PIN + alert config
* - water_admin_sessions — admin sign-in sessions (separate cookie)
* - water_audit_log — who changed what, when
*/
import {
pgTable,
@@ -8,7 +18,13 @@ import {
numeric,
boolean,
timestamp,
date,
jsonb,
doublePrecision,
index,
uniqueIndex,
integer,
check,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
import { adminUsers } from "./brands";
@@ -21,11 +37,29 @@ export const waterHeadgates = pgTable(
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
/** Per-headgate opaque token used in the QR code. */
headgateToken: text("headgate_token").notNull().unique(),
/** Open / Closed / Maintenance. */
status: text("status").notNull().default("open"),
/** Display unit: CFS, GPM, Inches, AF/Day, etc. */
unit: text("unit").notNull().default("CFS"),
/** Optional max-flow marker in GPM. */
maxFlowGpm: numeric("max_flow_gpm"),
/** High-water alert threshold (units match `unit`). */
highThreshold: numeric("high_threshold"),
/** Low-water alert threshold (units match `unit`). */
lowThreshold: numeric("low_threshold"),
notes: text("notes"),
active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("water_headgates_brand_idx").on(t.brandId),
tokenIdx: uniqueIndex("water_headgates_token_idx").on(t.headgateToken),
}),
);
export const waterIrrigators = pgTable(
@@ -40,12 +74,19 @@ export const waterIrrigators = pgTable(
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(
@@ -75,10 +116,20 @@ export const waterLogEntries = pgTable(
irrigatorId: uuid("irrigator_id")
.notNull()
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
/** Raw measurement value, in the entry's `unit`. */
measurement: numeric("measurement").notNull(),
unit: text("unit").notNull(),
/** "manual" | "meter" | "estimate" | "qr" */
method: text("method").notNull().default("manual"),
/** Optional auto-computed total (in gallons) when CFS × duration is known. */
totalGallons: numeric("total_gallons"),
notes: text("notes"),
submittedVia: text("submitted_via").notNull().default("app"),
photoUrl: text("photo_url"),
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
/** Date-only mirror of loggedAt for fast grouping / dashboard queries. */
loggedDate: date("logged_date"),
loggedAt: timestamp("logged_at", { withTimezone: true })
.notNull()
.defaultNow(),
@@ -87,6 +138,14 @@ export const waterLogEntries = pgTable(
(t) => ({
brandIdx: index("water_log_entries_brand_idx").on(t.brandId),
headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId),
brandDateIdx: index("water_log_entries_brand_date_idx").on(
t.brandId,
t.loggedDate,
),
irrigatorIdx: index("water_log_entries_irrigator_idx").on(
t.irrigatorId,
t.loggedAt,
),
}),
);
@@ -98,7 +157,9 @@ export const waterAlertLog = pgTable(
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
alertType: text("alert_type").notNull(),
headgateId: uuid("headgate_id").references(() => waterHeadgates.id),
headgateId: uuid("headgate_id").references(() => waterHeadgates.id, {
onDelete: "set null",
}),
message: text("message").notNull(),
sentTo: text("sent_to"),
sentAt: timestamp("sent_at", { withTimezone: true }),
@@ -108,8 +169,89 @@ export const waterAlertLog = pgTable(
},
);
export const waterAdminSettings = pgTable("water_admin_settings", {
brandId: uuid("brand_id")
.primaryKey()
.references(() => brands.id, { onDelete: "cascade" }),
/** Hashed admin PIN (scrypt $ N$r$p$salt$hash format). */
pinHash: text("pin_hash"),
enabled: boolean("enabled").notNull().default(true),
sessionDurationHours: integer("session_duration_hours")
.notNull()
.default(4),
canEditEntries: boolean("can_edit_entries").notNull().default(true),
canDeleteEntries: boolean("can_delete_entries").notNull().default(true),
canExportCsv: boolean("can_export_csv").notNull().default(true),
alertPhone: text("alert_phone"),
alertsEnabled: boolean("alerts_enabled").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedBy: uuid("updated_by").references(() => adminUsers.id),
});
export const waterAdminSessions = pgTable(
"water_admin_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
adminUserId: uuid("admin_user_id")
.notNull()
.references(() => adminUsers.id, { onDelete: "cascade" }),
pinHashUsed: text("pin_hash_used").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
adminIdx: index("water_admin_sessions_admin_idx").on(
t.adminUserId,
t.expiresAt,
),
}),
);
export const waterAuditLog = pgTable(
"water_audit_log",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
actorId: uuid("actor_id").references(() => adminUsers.id),
actorLabel: text("actor_label").notNull(),
action: text("action").notNull(),
entityType: text("entity_type").notNull(),
entityId: uuid("entity_id"),
details: jsonb("details"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandRecentIdx: index("water_audit_log_brand_recent_idx").on(
t.brandId,
t.createdAt,
),
}),
);
// ── Inferred types (used by every action and client component) ────────────
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
export type WaterSession = typeof waterSessions.$inferSelect;
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
export type WaterLogEntryInsert = typeof waterLogEntries.$inferInsert;
export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
export type WaterAuditLog = typeof waterAuditLog.$inferSelect;
File diff suppressed because one or more lines are too long
+255
View File
@@ -0,0 +1,255 @@
# Nested Layout Skeleton
Visual map of how pages nest inside each other in the Next.js App Router.
Each level persists across navigation — children swap, parents stay.
## Mental model
```
┌─────────────────────────────────────────────────────────────────┐
│ app/layout.tsx (root) │
│ html / body / fonts / Providers / Toast / CookieBanner │
│ ───────────────────────────────────────────────────────── │
│ No global header. Each route group renders its own chrome. │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────┼─────────────────────────────┐
│ │ │
▼ ▼ ▼
Public routes Storefronts Admin
(/, /pricing, (/tuxedo/*, (/admin/*)
/contact, /login) /indian-river-direct/*)
```
## Full tree
```
src/app/
├── layout.tsx ────────────────────────────────────────────────── [ROOT]
│ • <html>, <body>
│ • next/font: Fraunces (display), Manrope (sans), Fragment_Mono
│ • <Providers> (theme, posthog, sentry, etc.)
│ • <ToastNotificationContainer />
│ • <CookieConsentBanner />
│ ─── pages render INSIDE ───
│ │
│ ├── error.tsx [ROOT-LEVEL] Atelier error page
│ ├── loading.tsx [ROOT-LEVEL] "Preparing your harvest"
│ ├── not-found.tsx [ROOT-LEVEL] "Off the beaten path"
│ │
│ ├── page.tsx / LandingPageClient
│ │ └── renders its own SiteHeader + Hero + SiteFooter
│ │
│ ├── login/
│ │ ├── page.tsx /login LoginClient (standalone)
│ │ └── loading.tsx [isolated]
│ │
│ ├── cart/
│ │ ├── page.tsx /cart CartClient (standalone)
│ │ └── loading.tsx
│ │
│ ├── checkout/
│ │ ├── page.tsx /checkout CheckoutClient (standalone)
│ │ ├── loading.tsx
│ │ └── success/page.tsx /checkout/success
│ │
│ ├── change-password/page.tsx
│ ├── pricing/page.tsx + loading.tsx
│ ├── contact/page.tsx + loading.tsx
│ ├── blog/page.tsx
│ ├── changelog/page.tsx
│ ├── roadmap/page.tsx
│ ├── waitlist/page.tsx
│ ├── security/page.tsx
│ ├── privacy-policy/page.tsx
│ ├── terms-and-conditions/page.tsx
│ ├── maintenance/page.tsx
│ │
│ ├── ─── STOREFRONTS ─────────────────────────────────────────
│ │
│ ├── tuxedo/
│ │ ├── layout.tsx ─────────────────────── [TUXEDO CHROME]
│ │ │ • Dark emerald gradient backdrop
│ │ │ • Ambient emerald orb (top-right blur)
│ │ │ • <StorefrontHeader> + <StorefrontFooter> per page
│ │ │ • BreadcrumbList + LocalBusiness JSON-LD in metadata
│ │ │ ─── INSIDE ───
│ │ │ ├── error.tsx
│ │ │ ├── loading.tsx
│ │ │ ├── page.tsx /tuxedo
│ │ │ ├── stops/
│ │ │ │ ├── page.tsx /tuxedo/stops
│ │ │ │ ├── loading.tsx
│ │ │ │ └── [slug]/
│ │ │ │ ├── page.tsx /tuxedo/stops/:slug
│ │ │ │ ├── error.tsx
│ │ │ │ └── loading.tsx
│ │ │ ├── about/
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── page.tsx /tuxedo/about
│ │ │ │ ├── error.tsx
│ │ │ │ └── loading.tsx
│ │ │ ├── faq/
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── page.tsx /tuxedo/faq
│ │ │ │ ├── error.tsx
│ │ │ │ └── loading.tsx
│ │ │ ├── contact/
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── page.tsx /tuxedo/contact
│ │ │ │ ├── error.tsx
│ │ │ │ └── loading.tsx
│ │ │ ├── products/
│ │ │ │ └── [slug]/page.tsx /tuxedo/products/:slug
│ │ │ └── time-clock/page.tsx /tuxedo/time-clock
│ │
│ ├── indian-river-direct/
│ │ ├── layout.tsx ─────────────────── [IRD CHROME]
│ │ │ • Light blue/white glass gradient backdrop
│ │ │ • Three decorative blue orbs
│ │ │ • <StorefrontHeader> + <StorefrontFooter> per page
│ │ │ • LocalBusiness (FL, family-owned 1985) JSON-LD
│ │ │ ─── INSIDE ───
│ │ │ ├── error.tsx
│ │ │ ├── loading.tsx
│ │ │ ├── page.tsx /indian-river-direct
│ │ │ ├── stops/
│ │ │ │ ├── page.tsx
│ │ │ │ └── [slug]/
│ │ │ │ ├── page.tsx
│ │ │ │ ├── error.tsx
│ │ │ │ └── loading.tsx
│ │ │ ├── about/ (layout + page + error + loading)
│ │ │ ├── faq/ (layout + page + error + loading)
│ │ │ └── contact/ (layout + page + error + loading)
│ │
│ ├── ─── ADMIN ──────────────────────────────────────────────
│ │
│ └── admin/
│ ├── layout.tsx ───────────────────── [ADMIN SHELL]
│ │ • dynamic = "force-dynamic" (reads cookies)
│ │ • getAdminUser() → AdminSidebar + content area
│ │ • ToastProvider + ToastContainer
│ │ • Sidebar persists across all /admin/* routes
│ │ • Parchment background via .admin-section
│ │ ─── INSIDE ───
│ │ ├── error.tsx Admin error page
│ │ ├── loading.tsx Skeleton: header + stats + table
│ │ ├── page.tsx /admin (Dashboard)
│ │ │
│ │ ├── orders/
│ │ │ ├── page.tsx /admin/orders
│ │ │ ├── [id]/page.tsx /admin/orders/:id
│ │ │ └── new/page.tsx /admin/orders/new
│ │ │
│ │ ├── products/
│ │ │ ├── page.tsx /admin/products
│ │ │ ├── [id]/page.tsx
│ │ │ ├── new/page.tsx
│ │ │ └── import/page.tsx
│ │ │
│ │ ├── stops/
│ │ │ ├── page.tsx /admin/stops
│ │ │ ├── [id]/page.tsx
│ │ │ └── new/page.tsx
│ │ │
│ │ ├── communications/
│ │ │ ├── loading.tsx
│ │ │ ├── page.tsx /admin/communications
│ │ │ ├── compose/page.tsx
│ │ │ ├── campaigns/[id]/page.tsx
│ │ │ ├── contacts/page.tsx
│ │ │ ├── logs/page.tsx
│ │ │ ├── segments/page.tsx
│ │ │ ├── settings/page.tsx
│ │ │ ├── templates/page.tsx
│ │ │ ├── templates/[id]/page.tsx
│ │ │ ├── analytics/page.tsx
│ │ │ ├── abandoned-carts/page.tsx
│ │ │ └── welcome-sequence/page.tsx
│ │ │
│ │ ├── settings/ (sub-tree of 13 pages: billing, brand,
│ │ │ payments, shipping, ai, apps,
│ │ │ integrations, square-sync, ...)
│ │ │
│ │ ├── route-trace/ (lots, lookup, settings)
│ │ ├── me/
│ │ ├── pickup/ Store-employee landing
│ │ ├── sales/import/
│ │ ├── import/
│ │ ├── analytics/
│ │ ├── command-center/
│ │ ├── advanced/
│ │ ├── reports/
│ │ ├── shipping/
│ │ ├── taxes/
│ │ ├── time-tracking/
│ │ ├── water-log/ (sub-tree)
│ │ ├── wholesale/
│ │ └── launch-checklist/
│ │
│ ├── wholesale/ (separate sub-app, not under admin/layout)
│ │ ├── login/page.tsx
│ │ ├── portal/page.tsx
│ │ ├── register/page.tsx
│ │ ├── employee/page.tsx
│ │ └── payment/{success,cancel}/page.tsx
│ │
│ ├── water/ (separate sub-app, not under admin/layout)
│ │ ├── page.tsx
│ │ ├── loading.tsx
│ │ └── admin/ (login + page)
│ │
│ ├── ird/time-clock/ (time-clock for IRD employees)
│ ├── trace/[lotNumber]/page.tsx (public route-trace lookup)
│ └── protected-example/page.tsx (smoke test for middleware)
```
## Key nesting rules
1. **The root `app/layout.tsx` is the only one that renders `<html>` and `<body>`**. Every other layout is a child of it.
2. **A `layout.tsx` in a folder wraps every `page.tsx` in that folder and all subfolders**. Children swap; layout state persists.
3. **`loading.tsx` and `error.tsx` are per-folder** — Next.js shows them when navigating to a sibling `page.tsx`. They re-render as you move between sections.
4. **Storefronts are fully isolated**: each `tuxedo/layout.tsx` and `indian-river-direct/layout.tsx` provides its own backdrop and lets individual pages render their own `StorefrontHeader` / `StorefrontFooter`.
5. **Admin is a single SPA-feeling shell**: navigating between `/admin/orders` and `/admin/products` keeps the `AdminSidebar` mounted, only the content area swaps.
## Things that DO NOT yet nest (gaps)
| Page | Issue | Fix |
|---|---|---|
| `/cart` | No shared layout with `/checkout` | Add `app/(shop)/layout.tsx` route group to wrap both |
| `/wholesale/*` | Wholesale pages have no shared layout | Add `app/wholesale/layout.tsx` with auth gate + nav |
| `/water/*` | Water pages have no shared layout | Add `app/water/layout.tsx` |
| `/admin/communications/*` | 13 pages share no sub-layout | Could add `app/admin/communications/layout.tsx` with section tabs |
| `/admin/settings/*` | 13 settings pages share no sub-layout | Add `app/admin/settings/layout.tsx` with settings sidebar |
## How to add a nested layout
```bash
# Example: add a settings sub-layout
mkdir -p src/app/admin/settings
# Create src/app/admin/settings/layout.tsx
# It will wrap every page under /admin/settings/* automatically.
```
```tsx
// src/app/admin/settings/layout.tsx
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 lg:grid-cols-[14rem_1fr] gap-6">
<SettingsNav /> {/* sticks while content swaps */}
<section>{children}</section>
</div>
);
}
```
## How to add parallel routes (modals as overlays)
```bash
mkdir -p 'src/app/@modal'
mkdir -p 'src/app/admin/products/(.)new' # intercepts /admin/products/new
# Layout returns <>{children}{modal}</>
```
This makes `/admin/products/new` open as a modal over `/admin/products` instead of a hard navigation.
+1 -1
View File
@@ -12,7 +12,7 @@ A **multi-tenant B2B produce distribution platform**, not a SaaS starter. Build
|---|---|
| Migrations | **135 SQL files** (production schema, RLS, SECURITY DEFINER RPCs, audit logs) |
| Source | **544 files** (362 .tsx + 179 .ts) |
| Admin pages | **88 routes** across orders, products, stops, wholesale, communications, time-tracking, water-log, route-trace, reports, advanced, command-center |
| Admin pages | **87 routes** across orders, products, stops, wholesale, communications, time-tracking, water-log, route-trace, reports, advanced |
| API routes | **60+ endpoints** (Stripe + Square + FedEx + Resend + Twilio + 8 AI endpoints) |
| Public surfaces | 2 brand storefronts (Tuxedo, Indian River Direct) with brand theming, hero, color customization |
| Integrations | Stripe (Connect + subscriptions), Square (POS sync), FedEx (rates + labels), Resend (email), Twilio (SMS), OpenAI (8 AI tools) |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
# Admin Redesign — Task Checklist
> Working branch: `design/ui-revamp-2026-06`
> Spec: `docs/superpowers/specs/2026-06-17-admin-redesign.md`
> Started: 2026-06-17
> Deadline: tomorrow
## How to use this file
- `[ ]` unchecked → not done · `[x]` checked → done
- Tick a box by changing `[ ]` to `[x]`
- Add free-form notes in the **Notes** column
- One commit per task (or per cluster of related tasks)
- Revert a task with `git reset --hard <commit-before-task>`
- Full revert: `git checkout main && git branch -D design/ui-revamp-2026-06`
## Revert cheatsheet
```bash
git status # what's dirty
git stash # save uncommitted, discard
git diff # see what's staged
git log --oneline -10 # see recent commits
git reset --hard <sha> # nuke back to that commit
git checkout main # abandon branch, keep work
git branch -D design/ui-revamp-2026-06 # delete the branch
```
---
## Phase 0 — Setup
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [x] | Create feature branch | — | `design/ui-revamp-2026-06` off `main` | — |
| [x] | Write design spec | `docs/superpowers/specs/2026-06-17-admin-redesign.md` | aesthetic, color, IA, phases | — |
| [x] | Write this checklist | `docs/superpowers/plans/2026-06-17-admin-redesign-checklist.md` | — | — |
| [ ] | Commit baseline | both files above | `chore: design spec + checklist` | (will commit after writing this) |
## Phase 1 — Foundation (color + design tokens)
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [x] | Replace `--admin-*` color tokens in admin-design-system.css | `src/styles/admin-design-system.css` | use spec §3 table. Drop the muddy `aba278` warning, the purple stat icons, the blue stat icons. | `style(admin): unify color tokens` (18fb44e) |
| [x] | Update `globals.css` if any admin colors live there | `src/app/globals.css` | grep for `admin-accent`, `admin-bg` | (same commit) |
| [x] | Remove inline `#fef3c7` / `#dbeafe` / `#f3e8ff` from stat cards in DashboardClient | `src/components/admin/DashboardClient.tsx` | semantic colors only | (same commit) |
## Phase 2 — Discoverability (sidebar + command palette)
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [x] | Create `SideNavGroup` component (label + items + active highlight) | `src/components/admin/SideNavGroup.tsx` (new) | matches new IA: Workspace / Operations / Communications / Growth / Tracking / Insights / Settings | (4ded68c) |
| [x] | Refactor `AdminSidebar` to use grouped nav | `src/components/admin/AdminSidebar.tsx` | replace flat `NAV_ITEMS` with groups; keep role-gating, brand selector, sign-out | `feat(admin): grouped sidebar IA` (4ded68c) |
| [x] | Create `CommandPalette` (Cmd+K) | `src/components/admin/CommandPalette.tsx` (new) | global search across pages + recent items + quick actions; mounts in admin layout | `feat(admin): command palette` (750efdd) |
| [x] | Mount `CommandPalette` in admin layout | `src/app/admin/layout.tsx` | wraps children, listens for `Cmd/Ctrl+K` | (442c16d) |
| [x] | Plumb `enabledAddons` to sidebar (gates Water Log / Route Trace) | `src/app/admin/layout.tsx` | `getEnabledAddons(activeBrandId)` from `actions/billing/stripe-portal` | (442c16d) |
| [ ] | Settings pages get a top tab bar (no more hidden sub-nav) | `src/app/admin/settings/*/page.tsx` | General / Brand / Billing / Users / Integrations / Payments / Shipping / Add-ons | `feat(admin): settings top tabs` |
## Phase 3 — Component patterns
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [x] | Extract `KPIStat` from DashboardClient | `src/components/admin/KPIStat.tsx` (new) | label + value + optional trend | `feat(admin): KPIStat component` (8e93734) |
| [x] | Tighten `AdminBadge` variants (status / tier / addon) | `src/components/admin/design-system/AdminBadge.tsx` | semantic: success / warning / danger / info / neutral | (8e93734) |
| [x] | Add `EmptyState` admin variant | `src/components/admin/EmptyState.tsx` (new) | icon + title + body + CTA; matches color tokens | (8e93734) |
| [x] | Add `LoadingState` admin variant | `src/components/admin/LoadingState.tsx` (new) | uses `.ha-skeleton` | (8e93734) |
| [ ] | Audit `PageHeader` usage; fix inconsistencies | `src/components/admin/design-system/PageHeader.tsx` + callers | eyebrow + title + subtitle + actions; consistent across all pages | `chore(admin): PageHeader audit` |
## Phase 4 — Dashboard (unified command center)
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [ ] | Redesign `DashboardClient` as single feed (no 4 tabs) | `src/components/admin/DashboardClient.tsx` | top: KPI strip · middle: "Needs attention" feed · bottom: section grid | `feat(admin): unified dashboard` |
| [ ] | Add a "What needs attention" feed (orders pending, stops today, low stock, unanswered contacts) | same file | data from existing actions | (same commit) |
## Phase 5 — Apply patterns to top pages
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [x] | `/admin/orders` list + detail | `src/app/admin/orders/page.tsx`, `[id]/page.tsx`, `AdminOrdersPanel.tsx` | new PageHeader, StatusPill, EmptyState | `feat(admin): apply design system to Orders` (467f7e6) |
| [x] | `/admin/products` list + new + edit | `src/app/admin/products/page.tsx`, `new/page.tsx`, `[id]/page.tsx`, `ProductsClient.tsx` | same | `feat(admin): apply new design system to Products pages` (1e0e278) |
| [x] | `/admin/stops` calendar | `src/app/admin/stops/page.tsx`, `StopsCalendarClient.tsx` | same | `feat(admin): apply design system to Stops pages` (685a126) |
| [ ] | `/admin/communications` composer | `src/components/admin/HarvestReach/CampaignComposerPage.tsx` | same | `style(admin): comms composer` |
| [ ] | `/admin/settings` index + tab bar | `src/app/admin/settings/page.tsx` | overview of all settings w/ quick links | `style(admin): settings index` |
## Phase 6 — Frontend polish (public side)
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [ ] | Tuxedo home polish | `src/app/tuxedo/page.tsx` + `tuxedo/` components | rhythm, spacing, mobile | `style(storefront): tuxedo polish` |
| [ ] | Indian River Direct home polish | `src/app/indian-river-direct/page.tsx` + components | rhythm, spacing, mobile | `style(storefront): IRD polish` |
| [ ] | Pricing page polish | `src/app/pricing/page.tsx` | tier card spacing | `style(public): pricing polish` |
## Phase 7 — Verify (gate before merge)
| Done | Task | Command | Notes | Commit |
|---|---|---|---|---|
| [ ] | Type-check clean | `npx tsc --noEmit` | 0 errors | (no commit, gate) |
| [ ] | Build clean | `npm run build` | green | (no commit, gate) |
| [ ] | Dev server boots | `npm run dev` | `/admin` renders, sidebar works, Cmd+K opens | (no commit, gate) |
| [ ] | Manual smoke: dashboard | `localhost:4000/admin` | layout intact, no console errors | (no commit, gate) |
| [ ] | Manual smoke: command palette | `Cmd+K` | opens, type, jump | (no commit, gate) |
| [ ] | Manual smoke: orders list | `/admin/orders` | loads, page header consistent | (no commit, gate) |
---
## Live notes (free-form)
Add anything you want to remember as we go.
- The existing `.ha-*` (Harvest Almanac) class system is good. Build on it; don't replace it.
- The sidebar is in `src/components/admin/AdminSidebar.tsx` (~520 lines). Don't rewrite from scratch — refactor the data structure to groups and let the JSX mostly stand.
- `lucide-react` is already a dependency. New icons should use lucide, not inline SVGs.
- The `Atelier des Récoltes` CSS classes (`.atelier-*` in `globals.css`) are public-side only. Don't mix them into admin.
- Per MEMORY.md: spawn sub-agents sequentially if TPM rate limit hits. If parallel works, fine; if not, fall back.
## Decisions log
Record any calls you make mid-flight that change the spec.
- (none yet)
@@ -0,0 +1,538 @@
# Admin Mobile PWA — Design Spec
- **Date:** 2026-06-17
- **Status:** Draft, awaiting user review
- **Author:** Grok
- **Branch:** `main`
- **Scope:** Admin dashboard only (`/admin/*`), with full PWA + offline support
---
## Context
Route Commerce is used heavily on phones — the team operates out of offices, trucks, warehouses, and outdoor pickup stops. The current admin is mobile-responsive but **not mobile-first**, the PWA is half-built (manifest + service worker exist, but no icons, no install prompt wired up, no offline page, no service worker registration), and Apple HIG accessibility-tier legibility is not the design baseline.
**Symptoms today:**
- `public/icons/` and `public/screenshots/` directories don't exist; manifest references 8 icon files and 2 screenshots that all 404
- `registerServiceWorker()` is exported but never called
- `PWAInstallPrompt` is never mounted in the tree
- `apple-touch-icon.png` and `favicon.ico` are referenced but missing
- Service worker references `/offline` and `/og-default.jpg` that don't exist
- `themeColor` in `viewport` (`#0a0a0a`) mismatches manifest (`#1a4d2e`)
- `AdminSidebar` uses `lg:pl-60` — on phones it becomes an overlay drawer; no bottom nav, no thumb-zone ergonomics
- Body text is 16pt; tap targets are 4044pt; status colors don't all clear WCAG AA on the warm cream background
- No offline mutation queue — losing signal mid-fulfillment is a real operational problem
## Goals
1. **Ship a real, installable PWA** — manifest validates, all icons render, splash + app icon work on iOS Add to Home Screen, themed status bar.
2. **Mobile-first admin shell** — bottom tab bar (thumb zone) for the 4 most-used surfaces, drag-up "More" sheet for everything else, sticky top bar with brand selector + search + profile.
3. **HIG Accessibility-tier legibility** — 18pt body, 500 weight, AAA contrast, 56pt+ tap targets, bold weights throughout. Readable in direct sunlight, tappable with gloves.
4. **Read + queue mutations offline** — operators in dead zones can still see data and queue actions (mark ready, mark picked up, change stop status, adjust stock). Optimistic UI with a visible sync status; replays on reconnect with last-write-wins conflict resolution.
5. **Production-optimized** — proper cache headers, code-split admin shell, SW cache versioning, no layout shift on font load, Lighthouse PWA + mobile a11y gates in CI.
## Non-Goals
- Redesigning marketing pages (landing, pricing, blog). They stay as-is.
- Redesigning the public storefronts. They stay as-is.
- Push notifications / Web Push. The SW has a `push` handler scaffolded but it's not in scope; requires VAPID setup and a separate decision.
- Wholesale portal mobile redesign. Tabled for a future spec.
- Real-time collaboration (websockets / live cursors). Out of scope.
- CRDT-style conflict resolution. We use last-write-wins keyed on `updated_at`.
## Surfaces in scope
- `/admin` (Dashboard)
- `/admin/orders` + `/admin/orders/[id]`
- `/admin/stops`
- `/admin/products`
- The admin shell that wraps all of the above
- PWA install, splash, offline page, and SW behavior across the whole app
---
## 1. Aesthetic direction & design system
### Direction: "HIG-First Field Almanac"
Apple HIG Accessibility-tier legibility is the **floor**; the existing editorial identity (Fraunces + Manrope + Fragment Mono, "Field Almanac" palette already hinted at in `globals.css`) pushed harder is the **ceiling**. The result feels like a high-end tool someone made for themselves — confident, large, warm, with serif labels and serious typography weight. Distinctive without being precious.
### Type scale (HIG Accessibility-tier)
| Token | Size / Weight | Use |
| ---------------- | ----------------------------------- | -------------------------------------- |
| `text-display` | 32pt / Fraunces 600 | Page titles (Dashboard, Orders, etc.) |
| `text-h1` | 24pt / Fraunces 600 | Section headers |
| `text-h2` | 19pt / Manrope 700 | Card titles, list primary |
| `text-body` | 18pt / Manrope 500 | All body text |
| `text-label` | 15pt / Manrope 600 / +0.02em | Button text, field labels, status pills|
| `text-mono` | 14pt / Fragment Mono 400 | Order IDs, totals, tracking numbers |
| `text-meta` | 13pt / Manrope 600 | Timestamps, helper text |
Body bumped from 16 → 18pt; weight bumped from 400 → 500 for sun legibility.
### Color tokens (AAA contrast where text appears)
| Token | Value | Use | Contrast on `bg` / `surface` |
| ------------- | --------- | --------------------------------------- | ---------------------------- |
| `bg` | `#ffffff` | App background | — |
| `surface` | `#faf8f5` | Page background (warm cream) | — |
| `surface-2` | `#f5f5f7` | Cards, raised surfaces | — |
| `surface-3` | `#e8e8ed` | Hover / pressed / skeletons | — |
| `text` | `#1d1d1f` | Primary text | AAA on all 4 surfaces |
| `text-muted` | `#424245` | Secondary text | AAA on all 4 surfaces |
| `text-faint` | `#5e5e63` | Tertiary / meta | AA on all 4 surfaces (≥ 5.28 : 1) |
| `accent` | `#14532d` | Primary actions, brand | AAA on `bg` (9.11), `surface` (8.59), `surface-2` (8.37), `surface-3` (7.46) |
| `accent-2` | `#166534` | Hover state (button background) | White text on `accent-2` = 7.13 : 1 (AAA) |
| `danger` | `#7f1d1d` | Errors, destructive | AAA on `bg` (10.02), `surface` (9.45), `surface-2` (9.20), `surface-3` (8.20) |
| `warning` | `#5e2a04` | Warnings, low stock | AAA on `bg` (11.61), `surface` (10.95), `surface-2` (10.66), `surface-3` (9.51) |
| `success` | `#14532d` | Success, completed | Mirrors `accent` |
| `info` | `#1e3a8a` | Informational | AAA on all 4 surfaces (8.4810.36) |
**Status text on its `-soft` pill background** is also AAA — the dark status text (e.g. `#14532d`) sits on a light tinted fill (e.g. `#dcfce7`) with ≥ 7:1 contrast. The full matrix (5 status × 4 surfaces + 5 status-on-soft + 4 body-text × 4 surfaces + 1 meta × 4 surfaces + 1 button-text + 5 soft-distinct = 35 assertions) is enforced by `src/lib/__tests__/design-tokens.test.ts`.
`text-faint` is the only token below AAA — used only for de-emphasized meta text where a stronger color would compete with primary content. All token combinations verified by hand calculation; the test suite in Section 5 enforces this in CI.
### Spacing (8pt grid)
`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64`
### Touch targets (HIG Accessibility + gloves)
- All interactive elements: **56pt min height** (HIG default 44pt → 56pt for gloves)
- Tab bar items: 60pt tall, icon 28pt + label 12pt
- List row primary action: full-row 72pt tap zone
- Bottom sheet drag handle: 56pt wide × 5pt tall
- FAB: 64pt diameter
- Minimum spacing between adjacent tap targets: 8pt (HIG recommends 16pt for gloves; we use 12pt to keep density reasonable)
### Motion
- All motion respects `prefers-reduced-motion`
- Card list items stagger in (16ms delay each, max 240ms total) on first mount
- Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out)
- StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out)
- Pull-to-refresh: custom indicator showing last-synced time in mono
- Default easing: `cubic-bezier(0.32, 0.72, 0, 1)` (Apple's standard)
---
## 2. Architecture & data flow
### Mobile admin shell
```
┌─────────────────────────────────┐
│ TopBar: [brand▼] [🔍] [👤] │ ← 56pt, sticky
├─────────────────────────────────┤
│ │
│ Page content │ ← scrollable, max 720pt content width
│ (card-stacked) │
│ │
├─────────────────────────────────┤
│ [Home][Orders][Stops][Products] │ ← bottom tab bar, 60pt, fixed
│ [More] │
└─────────────────────────────────┘
```
- **Bottom tab bar** (always visible, thumb zone): Dashboard · Orders · Stops · Products · **More**
- **"More" sheet** (drag-up full screen, 90% max-height): Communications, Pickup, Shipping, Reports, Analytics, Settings, etc. — every other admin link, organized into sections.
- **TopBar** (sticky): brand selector (left), search button (center, opens full-screen search), profile menu (right).
- **No desktop sidebar on mobile**`AdminSidebar` is hidden below `lg`. Above `lg`, sidebar is unchanged.
- A resize-aware `useMediaQuery('(min-width: 1024px)')` swaps between layouts inside `AdminShell`.
### New shared components
| File | Purpose |
| ------------------------------------------------------- | ------------------------------------------------------------------ |
| `src/components/admin/AdminShell.tsx` | Server-component shell that picks nav based on breakpoint |
| `src/components/admin/MobileTabBar.tsx` | Client component, fixed bottom, active state from `usePathname()` |
| `src/components/admin/MoreSheet.tsx` | Drag-up sheet for secondary nav, uses native `<dialog>` + View Transitions |
| `src/components/admin/PageHeader.tsx` | Consistent page title + actions row |
| `src/components/admin/StatusPill.tsx` | Color-coded pill (uses new status tokens) |
| `src/components/admin/EmptyState.tsx` | Used by all 4 key pages |
| `src/components/admin/CardList.tsx` + `CardListItem` | Replaces tables on mobile |
| `src/components/admin/StickyActionBar.tsx` | Primary action pinned to bottom of detail screens |
| `src/components/admin/OfflineBanner.tsx` | Top-of-screen offline indicator with pending sync count |
### Routing
No URL changes. The same `/admin/orders`, `/admin/stops`, `/admin/products` routes serve both layouts; the shell is purely presentational. Server components stay server components — the mobile shell is a thin client wrapper around them.
### Data flow
| Page | Server source | Mobile client behavior |
| ---------------- | ------------------------------------------------------ | ------------------------------------------------------------- |
| Dashboard | `get_dashboard_summary(brand_id)` (new RPC) | Cards, 2-col grid on tablet, 1-col on phone |
| Orders list | `get_orders_for_brand(brand_id, …)` (existing) | CardList with status pill, customer, total, time |
| Order detail | `get_order_detail(order_id)` (existing) | Header card + line items + timeline + StickyActionBar |
| Stops | `get_stops_for_brand(brand_id, …)` (existing) | CardList with time, address, status, "navigate" action |
| Products | `get_products_for_brand(brand_id, …)` (existing) | CardList with image, name, price, stock |
### Offline mutation queue
- `src/lib/offline/queue.ts` — IndexedDB store keyed by `clientActionId`
- `src/lib/offline/sync.ts` — replays queued actions on `online` event with exponential backoff
- Server actions stay the source of truth; the queue records `actionName + payload + clientActionId + createdAt` and posts them when online
- Each queued action shows in the UI with a "pending / synced / conflict" badge in `StickyActionBar`
- `OfflineBanner` shows when `navigator.onLine === false` and surfaces pending sync count
- **Conflict resolution:** last-write-wins keyed on `updated_at` returned by the server. After each sync, the client refetches the affected entity and merges.
- **Backoff:** 1s, 4s, 16s, 60s, 5min; give up after 5 attempts, surface a "Manual retry" button on the conflicting item.
- **Idempotency:** every queued action carries a `clientActionId` (uuid v4 generated at enqueue time). The server's RPC layer is updated to accept and dedupe on this id.
### New RPC (for dashboard)
```sql
CREATE OR REPLACE FUNCTION get_dashboard_summary(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
result JSONB;
BEGIN
SELECT jsonb_build_object(
'orders_today', (SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE),
'revenue_today', (SELECT COALESCE(SUM(total), 0) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE AND status NOT IN ('cancelled')),
'pending_fulfillment', (SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND status IN ('placed', 'ready')),
'stops_today', (SELECT COUNT(*) FROM stops WHERE brand_id = p_brand_id AND scheduled_at::date = CURRENT_DATE),
'orders_last_7_days', (
SELECT jsonb_agg(jsonb_build_object('date', d::date, 'count', COALESCE(o.cnt, 0)))
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') d
LEFT JOIN (
SELECT created_at::date AS day, COUNT(*) AS cnt
FROM orders
WHERE brand_id = p_brand_id AND created_at >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY 1
) o ON o.day = d::date
)
) INTO result;
RETURN result;
END;
$$;
```
---
## 3. PWA, offline, and production hardening
### PWA — what's broken and what we're fixing
| Issue today | Fix |
| ------------------------------------------------------ | ------------------------------------------------------------------ |
| `public/icons/` doesn't exist | Generate all 8 sizes (72512) PNG + maskable + badge + 3 shortcut icons |
| `public/screenshots/` doesn't exist | Generate `dashboard.png` (1280×720) and `mobile-storefront.png` (390×844) |
| `apple-touch-icon.png` missing | Generate 180×180 + 167×167 + 152×152 |
| `favicon.ico` missing | Generate from SVG |
| `sw.js` references `/offline` page that doesn't exist | Create `public/offline.html` (clean cream HIG-style page) |
| `sw.js` references `/og-default.jpg` (only .svg exists)| Update ref to `/og-default.svg` |
| `registerServiceWorker()` exported but never called | Call it from `src/components/Providers.tsx` (client side) |
| `PWAInstallPrompt` never mounted | Mount in `src/app/admin/layout.tsx` (admin is the installable surface) |
| `themeColor` mismatch (`#0a0a0a` vs `#1a4d2e`) | Standardize on `#166534` (forest-800) |
| Missing PWA meta tags | Add `apple-mobile-web-app-capable`, `application-name`, `mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style` |
| `viewport.maximumScale = 5` but no `viewportFit: cover`| Add `viewportFit: 'cover'` for notched devices |
### Service worker rewrite
Replace `public/sw.js` with a versioned, two-cache SW:
- **App shell cache** (e.g. `rc-shell-v3`): pre-caches `/`, `/admin`, `/manifest.json`, fonts, icons, offline page — install-time `cache.addAll(…)`.
- **Data cache** (e.g. `rc-data-v3`): stale-while-revalidate for `/api/orders`, `/api/stops`, `/api/products`, `/api/dashboard` — read offline, refetch in background.
- **Cache strategies:**
- Navigations → network-first, fall back to cache, fall back to `/offline.html`
- Static assets (`/_next/static/*`, `/icons/*`, `/screenshots/*`) → cache-first
- API GETs (`/api/*` non-mutation) → stale-while-revalidate
- API mutations (`POST`/`PATCH`/`DELETE`) → never cached, pass-through; offline behavior handled by the client queue
- **Versioning:** `CACHE_NAME` bumped on each release; activate handler deletes old caches; `skipWaiting()` + `clients.claim()` to roll out updates fast.
- **Logging:** all `console.log` calls guarded by `process.env.NODE_ENV !== 'production'`.
### Production hardening
- `next.config.ts` — add `headers()` for `Cache-Control: public, max-age=31536000, immutable` on `/_next/static/*` and `/icons/*`
- Font preloading: `next/font` already inlines the CSS — verify the `display: swap` strategy stays
- Image optimization: existing `next/image` config; add `priority` to the admin logo in the top bar
- Code splitting: admin layout stays in its own chunk; mobile shell is a dynamic import gated on viewport
- No layout shift on font load: `next/font` reserves metric-based space
### Files touched in this section
- `public/manifest.json` (theme_color + new screenshot paths)
- `public/sw.js` (full rewrite)
- `public/offline.html` (new)
- `public/icons/*.png` (8 + maskable + badge + 3 shortcuts = 13 new files)
- `public/screenshots/*.png` (2 new)
- `public/apple-touch-icon.png` + variants (3 new)
- `public/favicon.ico` (1 new)
- `src/app/layout.tsx` (add PWA meta + register SW via Providers)
- `src/components/Providers.tsx` (call `registerServiceWorker()` on mount)
- `src/app/admin/layout.tsx` (mount `PWAInstallPrompt`)
- `next.config.ts` (cache headers for static assets)
---
## 4. Page-by-page layouts
All pages share: top bar, page header (title + primary action), card-stacked body (no tables on phones), bottom tab bar. The desktop layout is unchanged above `lg`.
### Dashboard (`/admin`)
- **Page header:** "Today" (Fraunces 32pt) + date in mono + brand selector (in top bar)
- **Stat row** (2×2 grid on phone, 4×1 on tablet+): Orders Today · Revenue · Pending Fulfillment · Stops Today — each card is `surface-2` with a big Fraunces number, Manrope label, and a 7-day sparkline in mono
- **"Needs you" card** (only if pending actions > 0): top-of-page warning card with count + "View all" CTA
- **Today's stops timeline:** vertical timeline of upcoming stops, each row tappable
- **Aesthetic:** calm, editorial, "open the laptop on the couch" feel. Numbers in Fraunces do the talking.
### Orders list (`/admin/orders`)
- **Page header:** "Orders" + filter button (opens bottom sheet) + "+ New order"
- **Filter sheet:** status, date range, customer, stop — drag-up sheet, sticky apply bar
- **Card list** (one per order):
- Top row: status pill (left) · order # in mono · time-ago (right)
- Middle: customer name (Manrope 700 19pt) + item count ("4 items")
- Bottom: total (Fraunces 22pt) · arrow chevron
- Whole row: 72pt tap zone
- **Empty state:** "No orders yet" + illustration + "+ New order" CTA
- **Pull-to-refresh** at the top
### Order detail (`/admin/orders/[id]`)
- **Header card:** customer name (Fraunces 28pt), phone + email (tap-to-call / tap-to-email), order # in mono, status pill
- **Line items card:** each item is a sub-row — image (56pt square), name, qty × price, fulfillment icon (pickup vs ship)
- **Fulfillment timeline card:** horizontal stepper (Placed → Ready → Picked up) with timestamps in mono
- **Customer notes card** (if present): quoted, italic
- **StickyActionBar** (bottom, above tab bar): primary action changes by status:
- Placed → "Mark Ready" (forest-800 fill, full width, 56pt)
- Ready → "Mark Picked Up" (forest-800 fill)
- Picked up → "View Receipt" (outlined)
- **Secondary actions** (refund, edit, contact customer) live in a top-right "•••" menu
- **Offline pending badge:** if the order was just updated offline, the action bar shows "Pending sync" with a tiny spinner until the queue replays
### Stops (`/admin/stops`)
- **Page header:** "Stops" + date picker (large, tappable) + "+ New stop"
- **Time-grouped sections:** "Morning" / "Afternoon" / "Evening" with sticky section headers (Manrope 700 15pt, surface-50 bg, 8pt vertical padding)
- **Stop card:**
- Time (Fraunces 28pt, left) · address (Manrope 600 17pt) · customer count
- Status pill + "Navigate" icon button (opens maps app via `navigator.share` fallback to URL)
- Inline status changer: tap status pill → bottom sheet with status options
- Whole row: 88pt to accommodate time + address comfortably
- **Empty state:** "No stops scheduled" + "+ New stop"
### Products (`/admin/products`)
- **Page header:** "Products" + search input (always visible, prominent) + "+ New product"
- **Card list:**
- Image (64pt square, rounded-2xl, object-cover) · name (Manrope 700 17pt) · price (Fraunces 20pt)
- Stock row: "142 in stock" or "⚠ 8 left" or "✕ Out of stock" — color + icon, never color alone
- "Adjust stock" via long-press → bottom sheet with +/ stepper
- **Filter chips above list:** All · In stock · Low · Out · Hidden
- **Empty state:** "No products" + "+ New product"
### Motion (per page)
- Card list items stagger in (16ms delay each, max 240ms total) on first mount
- Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out)
- Pull-to-refresh: custom indicator showing last-synced time
- StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out)
- All motion respects `prefers-reduced-motion`
### Aesthetic summary
Editorial-meets-field. Fraunces for numbers, prices, and the few serif headlines; Manrope for everything else; Fragment Mono for IDs, totals, timestamps. Warm cream surfaces, deeply saturated status colors, generous breathing room. Reads like a high-end tool someone made for themselves — not a generic admin template.
---
## 5. Testing & verification
### Unit tests (Vitest)
- `src/lib/offline/queue.test.ts` — enqueue, dequeue, persist across page reload, idempotency on `clientActionId`
- `src/lib/offline/sync.test.ts` — replays on `online` event, exponential backoff (1s, 4s, 16s, 60s, 5min, give up at 5 attempts), conflict surfacing
- `src/lib/format-date.test.ts` — extend for new relative formats ("just now", "5m ago", "2h ago", "yesterday")
- `src/lib/__tests__/design-tokens.test.ts` — asserts all token combinations meet WCAG contrast on the surfaces they actually render on (body text on all 4 surfaces at AAA; status text on `bg` + `surface` + their `-soft` pill backgrounds at AAA; `text-faint` on all 4 surfaces at AA; `accent-2` as a button background with white text at AAA); fails CI if a new color regresses
### Component tests (Vitest + Testing Library)
- `MobileTabBar` — active state, more-sheet trigger, keyboard nav
- `StickyActionBar` — pending badge, conflict state
- `OfflineBanner` — appears on `offline` event, hidden on `online`
- `StatusPill` — every status × every color combination renders the right aria-label
- `MoreSheet` — opens via More button, traps focus, closes on Escape and backdrop click, restores focus on close
**UI primitives:** No dialog/sheet library (vaul, radix, headlessui) is currently in the project. `MoreSheet` and the various bottom sheets (filter, status changer, stock stepper) use the native `<dialog>` element with `showModal()` + the View Transitions API for open/close animation. This is a deliberate choice to keep the bundle small for the PWA. The native `<dialog>` is well-supported in Safari 15.4+, Chrome 37+, and Firefox 98+ — all versions we target.
**Pull-to-refresh:** Custom implementation using pointer events on a sentinel element at the top of each list. We avoid adding a third-party pull-to-refresh library to keep the bundle small. The custom implementation is ~80 lines, respects `overscroll-behavior: contain`, and falls back to no-op if the user has indicated motion sensitivity.
**Tap-to-call / tap-to-email:** Implemented with explicit `tel:` and `mailto:` URL schemes on `<a>` tags (not buttons that call `window.location`). This ensures the OS handles the routing (e.g., FaceTime prompts on iOS, Gmail/Outlook chooser on Android) and is accessible to screen readers out of the box.
**Color contrast test exclusions:** `text-faint` is the only token below WCAG AAA (it passes AA at ≥ 5.28:1). The test in `src/lib/__tests__/design-tokens.test.ts` will assert AAA (7:1) for every token combination *except* `text-faint`, which is tested at AA (4.5:1). This is documented in the test file as an intentional exception.
### E2E (Playwright)
New spec: `tests/mobile-admin/pwa-install.spec.ts`
- Loads `/admin` on iPhone 13 viewport
- Asserts manifest link present, manifest is valid JSON, all icons return 200
- Asserts `apple-touch-icon` link present
- Asserts service worker registers successfully (via `navigator.serviceWorker.ready`)
New spec: `tests/mobile-admin/orders-list.spec.ts`
- Loads `/admin/orders` on iPhone 13 viewport
- Asserts no horizontal scroll (page width ≤ viewport)
- Asserts every interactive element has touch target ≥ 48pt
- Asserts all text passes axe-core
New spec: `tests/mobile-admin/offline-queue.spec.ts`
- Loads `/admin/orders/<id>` on iPhone 13 viewport
- Goes offline (Playwright `context.setOffline(true)`)
- Clicks "Mark Ready"
- Asserts the optimistic UI updates
- Asserts the action bar shows "Pending sync" badge
- Goes back online
- Asserts the badge clears within 5s and the server has the new status
### Visual regression (Playwright `toHaveScreenshot`)
- All 4 key page screens at 390×844 (iPhone 13)
- All 4 key page screens at 1024×768 (iPad)
- MobileTabBar — light + dark
- OfflineBanner — visible, hidden
### Manual QA checklist (added to PR template)
- [ ] Read every new screen in direct sunlight (or under a 5000K desk lamp at full brightness)
- [ ] Tap every interactive element while wearing work gloves
- [ ] Verify install-to-home-screen on a real iPhone (Safari → Share → Add to Home Screen) and confirm the splash + app icon are correct
- [ ] Toggle airplane mode and exercise: orders list, order detail, stops, products, mark-ready, mark-picked-up, stop status change, stock adjust
- [ ] Run `npx lighthouse http://localhost:3000/admin --preset=mobile --view` and confirm Performance ≥ 90, Accessibility ≥ 95, PWA checks pass
### Lighthouse / PWA gate (CI)
- Add a CI job (`.gitea/workflows/pwa-audit.yml`) that runs `lighthouse-ci` against the preview URL on every PR
- PWA category must pass (installable, manifest valid, service worker registered, themed)
- Mobile accessibility score must be ≥ 95
### Documentation
- `docs/pwa-testing.md` — how to manually test PWA + offline on a real device
- `docs/admin-mobile.md` — design rationale + token reference for the team
### Files touched in this section
~8 new test files, 1 CI workflow, 2 docs, plus assertions added to `playwright.config.ts` to include the `mobile-admin` project at iPhone 13 viewport by default.
---
## File inventory (consolidated)
### New files
- `public/icons/icon-{72,96,128,144,152,192,384,512}x{72,96,128,144,152,192,384,512}.png` (8)
- `public/icons/icon-maskable-512x512.png` (1)
- `public/icons/badge-72x72.png` (1)
- `public/icons/{orders,products,stops}-shortcut.png` (3)
- `public/screenshots/dashboard.png` (1)
- `public/screenshots/mobile-storefront.png` (1)
- `public/apple-touch-icon.png` (180×180), `public/apple-touch-icon-167.png`, `public/apple-touch-icon-152.png` (3)
- `public/favicon.ico` (1)
- `public/offline.html` (1)
- `src/components/admin/AdminShell.tsx`
- `src/components/admin/MobileTabBar.tsx`
- `src/components/admin/MoreSheet.tsx`
- `src/components/admin/PageHeader.tsx`
- `src/components/admin/StatusPill.tsx`
- `src/components/admin/EmptyState.tsx`
- `src/components/admin/CardList.tsx`
- `src/components/admin/StickyActionBar.tsx`
- `src/components/admin/OfflineBanner.tsx`
- `src/lib/offline/queue.ts`
- `src/lib/offline/sync.ts`
- `src/lib/offline/db.ts` (IndexedDB wrapper)
- `src/lib/__tests__/design-tokens.test.ts`
- `src/lib/offline/queue.test.ts`
- `src/lib/offline/sync.test.ts`
- `tests/mobile-admin/pwa-install.spec.ts`
- `tests/mobile-admin/orders-list.spec.ts`
- `tests/mobile-admin/offline-queue.spec.ts`
- `.gitea/workflows/pwa-audit.yml`
- `docs/pwa-testing.md`
- `docs/admin-mobile.md`
- `db/migrations/NNN_dashboard_summary_rpc.sql` (new RPC for dashboard)
### Modified files
- `public/manifest.json` (theme_color, screenshot paths)
- `public/sw.js` (full rewrite)
- `src/app/layout.tsx` (PWA meta tags, viewport.fit, themeColor)
- `src/components/Providers.tsx` (call `registerServiceWorker()`)
- `src/app/admin/layout.tsx` (mount `PWAInstallPrompt` + `OfflineBanner`, swap sidebar for `AdminShell`)
- `src/app/admin/v2/page.tsx` (Dashboard card layout — new v2 route)
- `src/app/admin/v2/orders/page.tsx` (CardList — new v2 route)
- `src/app/admin/v2/orders/[id]/page.tsx` (card layout + StickyActionBar + offline-queue integration — new v2 route)
- `src/app/admin/v2/stops/page.tsx` (time-grouped card list — new v2 route)
- `src/app/admin/v2/products/page.tsx` (image-forward card list — new v2 route)
- `next.config.ts` (cache headers for static assets; cutover redirects in PRs 6 and 8)
- `playwright.config.ts` (add `mobile-admin` project at iPhone 13 viewport)
- `src/lib/pwa.ts` (small fixes: `load` listener guard, dev-only logging)
- `src/app/page.tsx` (favicon ref fix)
Note: the v1 admin pages (`src/app/admin/page.tsx`, `src/app/admin/orders/page.tsx`, etc.) are **not** modified by PRs 15. They continue to serve the existing desktop layout until the cutover PRs redirect their routes to v2. After cutover, the v1 files are deleted in a follow-up commit.
---
## Open questions (deferred)
- **Web Push notifications** — SW has a handler scaffolded, but enabling real pushes requires VAPID keys, a push service, and a UX decision. Tabled for a follow-up spec.
- **Wholesale portal mobile** — out of scope here; would benefit from the same design system once the admin is shipped.
- **App Store / Play Store packaging** — Capacitor / TWA is the obvious next step once the PWA is solid. Not in this spec.
- **Image upload from camera** — products/stops could use this; separate spec.
---
## Risks
1. **The existing admin pages have a lot of bespoke UI** (drag-and-drop, complex tables, modals). Some of it may need to be simplified for the mobile-first treatment, not just wrapped. We'll discover this during implementation; the design language and shared components are designed to absorb it.
2. **The IndexedDB queue + idempotency requires server-side support** (`clientActionId` parameter on the relevant RPCs). The migration for the new RPCs is in scope; the RPC changes for idempotency are flagged here so they don't surprise us.
3. **iOS PWA limitations** — no push, no background sync (until iOS 16.4+ and even then limited), no install prompt via API (we use the manual `beforeinstallprompt` only, plus the iOS "Add to Home Screen" instructions in our install prompt copy). We document these limitations in the install prompt UI.
4. **Outdoor screen testing is subjective.** The 5000K lamp proxy won't catch every case. Real device testing in sunlight is required for sign-off.
---
## Rollout plan
> **Note on feature flags:** The project has a `src/lib/feature-flags.ts` system, but it's for **brand-scoped add-on features** (Harvest Reach, Square Sync, etc.), not for app-wide UI rollouts. There is no general-purpose feature flag system in this codebase. We will use a **route-based dual-deployment** strategy for rollout:
>
> - PRs 12 (design system + PWA scaffolding) are non-breaking; they add the new infrastructure without changing existing routes.
> - PRs 35 (Orders, Stops, Products) ship the new mobile-first versions at **`/admin/v2/orders`**, **`/admin/v2/stops`**, **`/admin/v2/products`**, and **`/admin/v2/orders/[id]`** initially. The old `/admin/orders` etc. routes continue to work unchanged. Internal team dogfoods at `/admin/v2/*` for 12 weeks.
> - PR 6 cuts over: a redirect in `next.config.ts` sends `/admin/orders*``/admin/v2/orders*`, etc. The old routes are removed in a follow-up commit.
> - PR 7 (Dashboard at `/admin/v2`) follows the same pattern.
>
> This avoids the need to introduce a feature flag system, which would be a separate piece of work.
1. **PR 1 — Design system + admin shell** — type scale, color tokens, AdminShell, MobileTabBar, MoreSheet, offline queue + sync. No page changes yet. Lighthouse PWA gate added.
2. **PR 2 — PWA manifest + SW + icons + offline page** — PWA installable, splash, themed status bar. All tests pass.
3. **PR 3 — Orders v2 (list + detail)** at `/admin/v2/orders/*` — first page on the new design language. Validates the components in real use.
4. **PR 4 — Stops v2** at `/admin/v2/stops`
5. **PR 5 — Products v2** at `/admin/v2/products`
6. **PR 6 — Cutover Orders + Stops + Products** — redirects in `next.config.ts`; old routes removed in a follow-up commit
7. **PR 7 — Dashboard v2** at `/admin/v2` (depends on the new `get_dashboard_summary` RPC migration)
8. **PR 8 — Dashboard cutover** — redirect `/admin``/admin/v2`; remove old dashboard
Each PR is independently shippable and reversible. The desktop layout is never affected until cutover PRs land.
---
## Definition of done
- [ ] All 4 key pages ship the mobile-first treatment
- [ ] PWA installs on iOS Safari and Android Chrome
- [ ] Service worker pre-caches the app shell, swr-caches the data, falls back to `/offline` on navigation
- [ ] Offline mutation queue replays actions on reconnect with no data loss
- [ ] All interactive elements ≥ 48pt (aim 56pt) on mobile
- [ ] All text passes WCAG AAA (7:1) against its background
- [ ] Lighthouse mobile audit: Performance ≥ 90, Accessibility ≥ 95, PWA passes
- [ ] No horizontal scroll on any of the 4 key pages at iPhone 13 viewport
- [ ] `prefers-reduced-motion` honored
- [ ] All Playwright specs (PWA install, orders list, offline queue) pass in CI
- [ ] Visual regression baselines committed for all 4 key pages × 2 viewports
- [ ] Real-device install + offline testing documented and signed off
@@ -0,0 +1,135 @@
# Admin Redesign — 2026-06-17
> Branch: `design/ui-revamp-2026-06`
> Type: Hot take. Tomorrow deadline. Approved yolo.
> Goal: polish the public side, reimagine the admin, fix the colors, make things findable.
---
## 1. Context
- **Public side (`/`, `/tuxedo`, `/indian-river-direct`, pricing, blog, contact)** — "good, just needs polishing." The "Atelier des Récoltes" / "Field Almanac" editorial language is in place (cream + forest + gold + Fraunces + Manrope + Fragment Mono). Don't break it.
- **Admin (`/admin/*`)** — the surface that needs the most work.
- 15+ admin sections are split across 4 dashboard tabs (Operations / Fulfillment / Management / Tools). **The user can't find things.**
- Color palette mixes forest + citrus + sage + gold + warmred + surface greys. **The colors fight each other and feel muddy.**
- Sidebar has 6 flat items. "Settings" sub-pages are reachable only by knowing they exist. **Discoverability is bad.**
- The "Harvest Almanac" design system (`.ha-*` classes) exists but is underused. Most admin pages fall back to defaults.
## 2. Aesthetic direction (one paragraph, committed)
> **Editorial operations.** The admin should feel like the back office of an almanac press, not a generic SaaS dashboard. Warm cream canvas, deep botanical green for primary action, amber gold for the *one* thing you should do next, ink-black for text, a single soft beige for borders. The same typeface family as the public side (Fraunces / Manrope / Fragment Mono). Generous use of numerals in Fragment Mono so prices and counts read like a ledger. **One primary, one accent, four neutrals. That's it.** No purple, no teal, no citrus orange as a primary.
Commit to this and stop adding new colors.
## 3. Color system (final, do not deviate)
| Token | Hex | Use |
|---|---|---|
| `--admin-bg` | `#FAF7F0` | Page background (atelier cream) |
| `--admin-surface` | `#FFFFFF` | Cards, modals, sheets |
| `--admin-surface-sunken` | `#F4F1E8` | Inset wells, code blocks |
| `--admin-ink` | `#1A1814` | Primary text |
| `--admin-ink-muted` | `#73706B` | Secondary text |
| `--admin-ink-faint` | `#A8A29E` | Tertiary / placeholders |
| `--admin-line` | `#E8E4D7` | Borders, dividers |
| `--admin-line-strong` | `#D4CFBE` | Emphasized borders |
| `--admin-primary` | `#1F4D2A` | Primary action (deep botanical) |
| `--admin-primary-hover` | `#163C20` | Primary hover |
| `--admin-primary-soft` | `#E8F0E5` | Primary at 8% (selected rows, chips) |
| `--admin-accent` | `#B8761E` | Amber — "the one thing to do" |
| `--admin-accent-soft` | `#F7EBD5` | Amber at 12% |
| `--admin-success` | `#1F4D2A` | (same as primary; semantic) |
| `--admin-warning` | `#B8761E` | (same as accent; semantic) |
| `--admin-danger` | `#A8321C` | Errors / destructive |
| `--admin-danger-soft` | `#F7E3DE` | Danger at 12% |
**Drop**: `--admin-warning: #aba278` (looks like dirt), the old `citrus` orange in the active state, the blue/purple stat icons. Replace with semantic use of the new palette.
## 4. IA decision
Sidebar groups, top to bottom, with section dividers:
| Group | Items |
|---|---|
| **Workspace** | Dashboard · Command Center (platform_admin only) |
| **Operations** | Orders · Stops & Routes · Products · Driver Pickup · Shipping |
| **Communications** | Harvest Reach (campaigns, templates, contacts, segments, logs) |
| **Growth** | Wholesale · Import Center · AI Intelligence |
| **Tracking** | Time Tracking · Water Log · Route Trace |
| **Insights** | Reports · Tax Dashboard |
| **Settings** | General · Brand · Billing · Users & Permissions · Integrations · Payments · Shipping · Add-ons |
The old "Workers & PINs" sidebar item is a tab on Time Tracking now. Settings is one click → secondary nav inside settings (tabs at the top of the page).
**Cmd+K command palette** for global search across:
- Pages (jumps to any admin route)
- Recent orders / products / stops / customers (live search)
- Quick actions ("Create order", "Add product", "Send blast")
## 5. Component patterns (consistency pass)
| Pattern | Owner | Status |
|---|---|---|
| `PageHeader` (eyebrow + title + subtitle + actions) | `src/components/admin/design-system/PageHeader.tsx` | exists, audit usage |
| `EmptyState` (icon + title + body + CTA) | same dir, currently `EmptyState.tsx` exists for shared, admin needs its own | add `.ha-empty` |
| `LoadingState` (skeleton + label) | `.ha-skeleton` exists in CSS, no component | wrap |
| `ConfirmDialog` (destructive action) | `AdminDeleteConfirm.tsx` exists | audit usage |
| `StatusPill` (success/warn/danger/info/neutral) | `AdminBadge.tsx` exists, variants are off | tighten variants |
| `KPIStat` (label + value + trend) | inline in `DashboardClient` | extract to component |
| `CommandPalette` (Cmd+K) | **new** | new component |
| `SideNavGroup` (label + items) | **new** | replaces flat NAV_ITEMS |
| `DataTable` (sortable, filterable, paginated) | `shared/DataTable.tsx` exists | tighten + use everywhere |
## 6. Phased execution (yolo, one day)
**Phase 1 — Foundation (commit per file)**
- Update `src/styles/admin-design-system.css` color tokens
- New `SideNavGroup` component
- Replace `AdminSidebar` flat list with grouped nav
- New `CommandPalette` component (mounted in admin layout)
- Refactor `DashboardClient` to unified command center (drop 4 tabs, single column feed)
**Phase 2 — Pattern extraction (commit per file)**
- Extract `KPIStat` from dashboard
- Refine `EmptyState` admin variant
- `LoadingState` wrapper
- `StatusPill` tighten variants
**Phase 3 — Apply to highest-traffic pages**
- `/admin/orders` + detail
- `/admin/products` + new + edit
- `/admin/stops` calendar
- `/admin/communications` composer
- `/admin/settings/*` tabs
**Phase 4 — Frontend polish**
- Public page spot-check: hero spacing, section rhythm
- Verify TypeScript + build green
## 7. Risk & revert
- Working on branch `design/ui-revamp-2026-06` (already created off `main`).
- **Every milestone is its own commit.** `git reset --hard <sha>` reverts to that point.
- **One-click full revert:** `git checkout main && git branch -D design/ui-revamp-2026-06` — but only after a failed `git push`.
- Type-check (`npx tsc --noEmit`) + `npm run build` are the gate at the end of each phase. Build green = milestone accepted.
- If something breaks the admin layout at runtime, `git revert HEAD` unwinds the most recent commit without losing history.
- **Do not delete or rename files in this branch.** Only add, only modify. Easier to revert.
## 8. Out of scope (yolo tomorrow, not now)
- Wholesale portal pages (`/wholesale/*`)
- Water-log standalone pages
- Public marketing pages (pricing, blog, changelog) — polish later
- Wholesale auth migration (separate task per MEMORY.md)
- Migrating remaining legacy `supabase.from()` calls (separate task per MEMORY.md)
## 9. What "done" looks like
- [ ] Color tokens unified, no muddy mix
- [ ] Sidebar has 6 visible groups with section labels
- [ ] Cmd+K opens a command palette that can navigate to any admin page
- [ ] Dashboard is a single feed, no 4-tab structure
- [ ] Top 6 admin pages use the new PageHeader / StatusPill / EmptyState consistently
- [ ] `npx tsc --noEmit` clean
- [ ] `npm run build` clean
- [ ] Dev server boots, `/admin` renders, sidebar works, Cmd+K opens
+337
View File
@@ -0,0 +1,337 @@
# Water Log
A standalone irrigation / water-usage tracker for ditch riders, water
admins, and farm operators. Tracks flow measurements against physical
headgates, attributes them to named water users, and rolls them up for
reporting.
The module is **PIN-based** and lives entirely outside the platform
admin auth: an irrigator with a 4-digit PIN can submit entries from a
phone in the field without an account on the platform. Site admins
manage headgates, users, and settings from `/admin/water-log`.
## When to use this
- You run irrigation ditches / headgates and need to track daily
flow with named operators.
- You need a mobile-friendly entry surface that works in low
connectivity (no login, just a PIN).
- You want a per-brand, brand-scoped record of who recorded what,
with audit trail + CSV export for water-rights reporting.
## When NOT to use this
- For sensor/IoT integrations, use the time-series / `Square Sync`
flow rather than this module — entries here are hand-typed.
- For multi-tenant water-rights billing, use the wholesale deposit
flow; this module is a measurement ledger, not a billing system.
---
## Architecture at a glance
```
┌────────────────────────┐ ┌─────────────────────────┐
│ /water (PIN form) │ │ /water/admin/login │
│ irrigator → submit │ │ (water_admin PIN) │
│ → wl_session cookie │ │ → wl_admin_session │
└──────────┬─────────────┘ └──────────┬──────────────┘
│ │
│ ┌─────────────────────────┘
▼ ▼
┌─────────────────────────────────────┐
│ Postgres (RLS) — brand scoped │
│ water_headgates, water_irrigators, │
│ water_log_entries, water_sessions, │
│ water_admin_sessions, water_ │
│ audit_log, water_alert_log, │
│ water_admin_settings │
└─────────────────────────────────────┘
┌─────────────┴──────────────┐
│ /admin/water-log │
│ Site-admin CRUD + exports │
└────────────────────────────┘
```
**Two separate PIN surfaces:**
| Surface | Cookie | TTL | Purpose |
|---|---|---|---|
| `/water` (irrigator) | `wl_session` | 8 h | Submit flow entries |
| `/water/admin` (admin) | `wl_admin_session` | configurable (1168 h) | Manage headgates/users, view all entries |
Both cookies are `httpOnly`, `sameSite=lax`, and `secure` in production.
---
## Data model
### `water_headgates`
Physical gates a measurement is tied to.
| Column | Type | Notes |
|---|---|---|
| `id` | uuid PK | |
| `brand_id` | uuid FK → brands | brand scope (RLS) |
| `name` | text | e.g. "Upper Ditch Headgate" |
| `headgate_token` | text UNIQUE | opaque token used in the QR code |
| `status` | text | `open` / `closed` / `maintenance` |
| `unit` | text | default measurement unit (CFS, GPM, AF, …) |
| `max_flow_gpm` | numeric | optional marker |
| `high_threshold` | numeric | alert when reading > this |
| `low_threshold` | numeric | alert when reading < this |
| `notes` | text | |
| `active` | bool | soft-delete |
| `last_used_at` | timestamptz | updated on each entry |
| `created_at` | timestamptz | |
### `water_irrigators`
Field workers with PIN access.
| Column | Type | Notes |
|---|---|---|
| `id` | uuid PK | |
| `brand_id` | uuid FK → brands | |
| `name` | text | |
| `pin_hash` | text | scrypt `$N$r$p$salt$hash` — never plain |
| `role` | text | `irrigator` (submit only) or `water_admin` (manage) |
| `language_preference` | text | `en` / `es` |
| `phone` | text | optional |
| `notes` | text | |
| `active` | bool | soft-delete |
| `last_used_at` | timestamptz | |
| `created_at` | timestamptz | |
### `water_log_entries`
The actual readings.
| Column | Type | Notes |
|---|---|---|
| `id` | uuid PK | |
| `brand_id` | uuid FK → brands | |
| `headgate_id` | uuid FK | |
| `irrigator_id` | uuid FK | who submitted |
| `measurement` | numeric | raw value in `unit` |
| `unit` | text | CFS, GPM, AF/Day, etc. |
| `method` | text | `manual` / `meter` / `estimate` / `qr` |
| `total_gallons` | numeric | auto-computed when CFS × duration is known |
| `notes` | text | |
| `submitted_via` | text | `field` / `admin` / `qr` |
| `photo_url` | text | optional |
| `latitude` / `longitude` | double precision | optional GPS |
| `logged_date` | date | date-only mirror for fast grouping |
| `logged_at` | timestamptz | the actual time |
| `logged_by` | uuid FK → admin_users | set when a site admin enters data |
### Sessions
- `water_sessions` — short-lived (8 h) PIN sessions for irrigators.
- `water_admin_sessions` — admin sign-in sessions, TTL from
`water_admin_settings.session_duration_hours`.
### Audit + alerts
- `water_audit_log` — who changed what, when. Captures every
headgate/user/setting mutation with actor + JSON details.
- `water_alert_log` — high/low threshold breach history.
- `water_admin_settings` — per-brand admin PIN, permission flags,
alert config, session duration.
---
## Security model
### PIN hashing
PINs are hashed with **scrypt** (Node built-in `crypto.scryptSync`)
at N=16384, r=8, p=1, 32-byte key, with a per-PIN random salt. The
hash is self-describing: `scrypt$N$r$p$salt_b64$hash_b64`. We avoid
adding a `bcrypt` or `argon2` dependency — `scrypt` is in the Node
core and matches the password module already used elsewhere.
### Brute-force hardening
- 48 digit PINs are low entropy, so we:
- Reject "weak" PINs at generation (`generatePin` skips 1111, 1234,
palindromes, monotonic sequences).
- Add a 200 ms delay on every failed `verifyPin` call to slow
online guessing.
- Use `timingSafeEqual` for the hash comparison.
- Sessions are short-lived (8 h for irrigators, configurable 1168 h
for admins).
- The admin PIN can be regenerated from `/admin/water-log/settings`
regenerating invalidates all existing admin sessions.
### Auth gates
Every server action enforces one of:
- `requireFieldSession()` — reads `wl_session` cookie, looks up
`water_sessions` row, verifies `expires_at`. Returns userId +
brandId.
- `requireWaterAdminPermission()` — calls `getAdminUser()` and checks
`can_manage_water_log` OR `role === "platform_admin"`.
- `requireWaterAdminSession()` — reads `wl_admin_session` cookie and
verifies the row.
There is **no implicit "logged in"** state. Each action that mutates
data explicitly checks its gate.
### Data isolation
- All tables have RLS policies (`withBrand()` helper sets a
per-request GUC; the policy reads it).
- `globalThis.__TUXEDO_BRAND_ID__` is a one-time Tuxedo-brand hint for
cold-start paths; the active `getAdminUser().brand_id` always wins
on later requests.
- No Supabase REST, no service-role keys — all access is direct
through Drizzle over a single shared `pg` Pool (see `src/lib/db.ts`).
---
## Day-to-day usage
### Add a headgate
1. `/admin/water-log` → scroll to **Headgates****+ Add Headgate**.
2. Enter a unique name (e.g. "North Field Gate 1"), default unit,
optional thresholds.
3. The new headgate gets a printable QR code. Print it and stick it
on the gate.
### Add a water user
1. **Water Users****+ Add User**.
2. Enter name, choose role (Admin or Irrigator), pick language.
3. A 4-digit PIN is generated. **Write it down now** — it is shown
once and never recoverable.
4. Hand the PIN to the worker. They sign in at `/water` with just
that.
### Submit a flow entry (irrigator)
1. Open `/water` on a phone → enter 4-digit PIN.
2. Pick the headgate (or scan the QR code — it deep-links to that
gate).
3. Enter the measurement, duration, method, optional notes/photo.
4. Tap **Submit**. Total gallons is auto-computed when CFS × duration
is known.
5. If the reading crosses a high/low threshold, an alert row is
written to `water_alert_log` and (if enabled) an SMS is dispatched
to the configured phone.
### Edit or delete an entry (admin)
- From the **Recent Entries** table, click any row.
- Edit form is gated by `canEditEntries` / `canDeleteEntries` in
`water_admin_settings`. Defaults: edit ✅, delete ✅.
### Reset a PIN
- **Water Users** row → **Reset PIN** → new PIN is generated and
shown once.
### Export CSV
- **Recent Entries****Export CSV** button. Or hit
`GET /api/water-logs/export?format=csv` (auth-gated, requires
`can_manage_water_log`).
---
## API surface
| Route | Method | Auth | Purpose |
|---|---|---|---|
| `/api/water-admin-auth` | POST | none (PIN-protected) | Exchange admin PIN for `wl_admin_session` cookie |
| `/api/water-logs/export` | GET | admin | Stream entries as JSON or CSV |
`POST /api/water-admin-auth` body:
```json
{ "brandId": "64294306-5f42-463d-a5e8-2ad6c81a96de", "pin": "1234" }
```
On success, sets the `wl_admin_session` cookie. On failure, returns
a generic error (we don't leak whether the brand has a PIN configured
vs. whether the PIN is wrong).
---
## Testing
### Unit (Vitest)
```bash
npm test -- tests/unit/water-log
```
Covers:
- PIN format validation + weak-PIN detection
- scrypt round-trip + tampering
- Reporting utilities (CSV escaping, date filters, season detection)
- Display age helpers
### E2E (Playwright)
```bash
npx playwright test water-log
```
Covers:
- `/water` and `/water/admin/login` render with PIN form
- PIN input strips non-digits and caps at 4 chars
- Wrong PIN does not navigate
- `/admin/water-log/*` redirects unauthenticated users
- `/api/water-logs/export` and `/api/water-admin-auth` are auth-gated
For the full DB-backed workflow (add headgate → add user → submit →
export), set `WATER_LOG_E2E_DB=1` and run the same command against a
test database.
---
## Environment variables
No Water Logspecific env vars are required. The module uses the
existing `DATABASE_URL` and (optionally) the MinIO bucket
`MINIO_BUCKET_WATER_LOGS` for photo uploads.
If you want high/low threshold SMS alerts, configure
`/admin/water-log/settings` with a phone number — SMS dispatch uses
the brand's existing Twilio config (same as Harvest Reach).
---
## Migration history
- `0001_init.sql` — initial `water_*` tables + RLS policies.
- `0090_water_log_completion.sql` — adds `headgate_token`, threshold
fields, `role` on irrigators, `method` + `logged_date` on entries,
plus the `water_admin_*`, `water_audit_log`, and `water_alert_log`
tables.
---
## Admin function checklist
Manual regression pass after changes:
### Field side
- [ ] `/water` PIN screen loads, accepts 4 digits, rejects letters
- [ ] Wrong PIN shows an error, does not redirect
- [ ] Correct PIN shows the entry form with the user's headgates
- [ ] Submitting an entry creates a row, shows confirmation, returns
to the form
- [ ] QR-code link (`/water?h=TOKEN`) pre-selects the headgate
- [ ] High/low threshold entries write a `water_alert_log` row
### Admin side
- [ ] `/admin/water-log` shows headgates, users, recent entries
- [ ] Add headgate → appears in list + QR can be generated
- [ ] Edit headgate thresholds → reflected in the field form
- [ ] Add user → PIN shown once, user appears in list
- [ ] Reset PIN → new PIN shown, old sessions invalidated
- [ ] Edit entry → measurement, notes, method all updatable
- [ ] Delete entry → row removed (or soft-flagged per audit policy)
- [ ] Filter by date range / headgate / user / method works
- [ ] CSV export downloads valid CSV
- [ ] Audit log shows the actor for every change
### Settings + portal
- [ ] `/admin/water-log/settings` shows current config
- [ ] Toggling "Enable Admin Portal" blocks `/water/admin/login`
- [ ] Regenerating admin PIN signs out all current admin sessions
- [ ] Session duration slider clamps to 1168 hours
### Edge cases
- [ ] Zero data: empty states render, no crashes
- [ ] Invalid PIN: form rejects, error is friendly
- [ ] Duplicate headgate name: server rejects, UI shows error
- [ ] Very large measurement (1e9): renders, doesn't blow up float
- [ ] 1,000+ entries: list paginates, filters stay responsive
- [ ] Concurrent submissions: both rows present, no lost writes
+1 -2
View File
@@ -14,7 +14,6 @@ const eslintConfig = defineConfig([
"next-env.d.ts",
// Ignore legacy .js scripts that use CommonJS
"scripts/**",
"supabase/**",
"fix-agents.js",
]),
// Allow setState in useEffect for PWA prompts and client-side state initialization
@@ -26,7 +25,7 @@ const eslintConfig = defineConfig([
},
// Relax some rules for legacy code
{
files: ["db/**", "scripts/**", "supabase/**"],
files: ["db/**", "scripts/**"],
rules: {
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-explicit-any": "warn",
+26
View File
@@ -0,0 +1,26 @@
{
"ci": {
"collect": {
"url": [
"http://localhost:3000/admin/v2/orders",
"http://localhost:3000/admin/v2/stops",
"http://localhost:3000/admin/v2/products"
],
"numberOfRuns": 1,
"settings": {
"preset": "mobile",
"emulatedFormFactor": "mobile"
}
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.95 }],
"categories:pwa": "error",
"installable-manifest": "error",
"service-worker": "error",
"themed-omnibox": "warn"
}
}
}
}
+52 -12
View File
@@ -12,8 +12,9 @@ const nextConfig: NextConfig = {
// /home/tyler/package-lock.json as the root directory."
// The deploy runner's APP_DIR is /home/tyler/route-commerce, so
// resolving relative to the project root is correct both locally and
// in CI.
outputFileTracingRoot: ".",
// in CI. We resolve to an absolute path to avoid the warning in
// Next.js 16 which prefers absolute paths here.
outputFileTracingRoot: __dirname,
// Enable strict mode
reactStrictMode: true,
@@ -21,10 +22,6 @@ const nextConfig: NextConfig = {
// Optimize images
images: {
remotePatterns: [
{
protocol: "https",
hostname: "*.supabase.co",
},
{
protocol: "https",
hostname: "images.unsplash.com",
@@ -85,18 +82,56 @@ const nextConfig: NextConfig = {
},
],
},
{
source: "/_next/static/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
],
},
{
source: "/icons/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
],
},
{
source: "/screenshots/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=604800" },
],
},
];
},
// Redirects
async redirects() {
return [
// Redirect old paths if needed
// {
// source: '/old-path',
// destination: '/new-path',
// permanent: true,
// },
// Cutover (PR 6, Task 6.1): mobile-first admin lives at /admin/v2/*.
// Redirect the v1 list/detail paths to their v2 equivalents.
// Use `permanent: false` (307) so we can revert easily if a
// regression is caught in monitoring before the v1 files are
// removed in Task 6.2.
// PR 8, Task 8.1: redirect the bare /admin path to the v2 dashboard.
// The dashboard itself still lives at /admin (as the v1 page)
// until Task 8.2 removes it after a 3-day monitoring window —
// this redirect just points the v1 entry point at v2.
{ source: "/admin", destination: "/admin/v2", permanent: false },
{ source: "/admin/orders", destination: "/admin/v2/orders", permanent: false },
{ source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false },
{ source: "/admin/stops", destination: "/admin/v2/stops", permanent: false },
{ source: "/admin/products", destination: "/admin/v2/products", permanent: false },
// v1 admin pages that were removed when the supabase shim was
// deleted. There are no v2 equivalents yet (reports / taxes /
// settings sub-pages are still TBD on the v2 surface), so they
// redirect to the v2 dashboard for now. Once those modules ship
// on v2, the redirects can be tightened to point at the new
// pages.
{ source: "/admin/products/:id", destination: "/admin/v2/products", permanent: false },
{ source: "/admin/reports", destination: "/admin/v2", permanent: false },
{ source: "/admin/taxes", destination: "/admin/v2", permanent: false },
{ source: "/admin/settings/shipping", destination: "/admin/settings", permanent: false },
{ source: "/admin/settings/integrations", destination: "/admin/settings", permanent: false },
{ source: "/admin/settings/billing", destination: "/admin/settings", permanent: false },
];
},
@@ -111,6 +146,11 @@ const nextConfig: NextConfig = {
experimental: {
// Enable optimizePackageImports for better bundle size
optimizePackageImports: ["lucide-react", "@radix-ui/react-icons", "framer-motion"],
// Enable React's <ViewTransition> and Next.js' automatic route
// transitions. Combined with the smooth-transition wrappers around
// page content (see src/components/transitions), navigation feels
// like a single continuous app rather than a sequence of page loads.
viewTransition: true,
},
// Compiler options
+5 -2
View File
@@ -12,6 +12,7 @@
"migrate:one": "node scripts/migrate.js",
"db:migrate": "node scripts/migrate.js",
"db:seed": "tsx db/seed.ts",
"db:seed:tour": "node scripts/seed-tuxedo-2026.js",
"db:reset": "node scripts/db-reset.js",
"db:studio": "drizzle-kit studio",
"type-check": "npx tsc --noEmit",
@@ -29,17 +30,17 @@
"@google/generative-ai": "^0.24.1",
"@gsap/react": "^2.1.2",
"@neondatabase/auth": "^0.4.2-beta",
"@neondatabase/serverless": "^1.1.0",
"@sentry/nextjs": "^10.55.0",
"@stripe/react-stripe-js": "^6.6.0",
"@stripe/stripe-js": "^9.7.0",
"@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.105.3",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0",
"drizzle-orm": "^0.36.4",
"exceljs": "^4.4.0",
"framer-motion": "^12.40.0",
"gsap": "^3.15.0",
"idb": "^8.0.3",
"lucide-react": "^1.17.0",
"next": "^16.2.6",
"next-auth": "^5.0.0-beta.31",
@@ -59,6 +60,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@lhci/cli": "^0.15.1",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
@@ -73,6 +75,7 @@
"drizzle-kit": "^0.30.6",
"eslint": "^9",
"eslint-config-next": "16.2.5",
"fake-indexeddb": "^6.2.5",
"jsdom": "^25.0.1",
"pg": "^8.20.0",
"playwright": "^1.59.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+17 -85
View File
@@ -2,100 +2,32 @@
"name": "Route Commerce",
"short_name": "Route Commerce",
"description": "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution",
"start_url": "/",
"start_url": "/admin",
"scope": "/",
"display": "standalone",
"background_color": "#faf8f5",
"theme_color": "#1a4d2e",
"theme_color": "#166534",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
{ "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"categories": ["business", "productivity"],
"shortcuts": [
{
"name": "View Orders",
"short_name": "Orders",
"description": "View and manage orders",
"url": "/admin/orders",
"icons": [{ "src": "/icons/orders-shortcut.png", "sizes": "96x96" }]
},
{
"name": "Add Product",
"short_name": "Add Product",
"description": "Add a new product",
"url": "/admin/products/new",
"icons": [{ "src": "/icons/products-shortcut.png", "sizes": "96x96" }]
},
{
"name": "Create Stop",
"short_name": "Create Stop",
"description": "Schedule a new pickup stop",
"url": "/admin/stops/new",
"icons": [{ "src": "/icons/stops-shortcut.png", "sizes": "96x96" }]
}
{ "name": "View Orders", "short_name": "Orders", "url": "/admin/v2/orders", "icons": [{ "src": "/icons/orders-shortcut.png", "sizes": "96x96" }] },
{ "name": "Add Product", "short_name": "Add Product", "url": "/admin/v2/products/new", "icons": [{ "src": "/icons/products-shortcut.png", "sizes": "96x96" }] },
{ "name": "Create Stop", "short_name": "Create Stop", "url": "/admin/v2/stops/new", "icons": [{ "src": "/icons/stops-shortcut.png", "sizes": "96x96" }] }
],
"screenshots": [
{
"src": "/screenshots/dashboard.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Admin Dashboard"
},
{
"src": "/screenshots/mobile-storefront.png",
"sizes": "390x844",
"type": "image/png",
"form_factor": "narrow",
"label": "Mobile Storefront"
}
{ "src": "/screenshots/dashboard.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide", "label": "Admin Dashboard" },
{ "src": "/screenshots/mobile-storefront.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow", "label": "Mobile Storefront" }
],
"related_applications": [],
"prefer_related_applications": false
+87
View File
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#166534">
<title>Offline — Route Commerce</title>
<style>
:root {
--color-bg: #ffffff;
--color-surface: #faf8f5;
--color-text: #1d1d1f;
--color-text-muted: #424245;
--color-accent: #166534;
--color-warning: #854d0e;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Manrope, system-ui, sans-serif;
background: var(--color-surface);
color: var(--color-text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
-webkit-font-smoothing: antialiased;
}
.card {
max-width: 480px;
width: 100%;
background: var(--color-bg);
border-radius: 24px;
padding: 48px 32px;
text-align: center;
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
}
.icon {
width: 64px;
height: 64px;
margin: 0 auto 24px;
background: var(--color-warning);
color: white;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
}
h1 {
font-family: Fraunces, Georgia, serif;
font-size: 32px;
font-weight: 600;
margin: 0 0 12px;
letter-spacing: -0.01em;
}
p {
font-size: 18px;
line-height: 1.45;
color: var(--color-text-muted);
margin: 0 0 24px;
}
button {
background: var(--color-accent);
color: white;
border: 0;
border-radius: 12px;
padding: 16px 32px;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
min-height: 56px;
}
button:active { opacity: 0.85; }
</style>
</head>
<body>
<div class="card" role="status">
<div class="icon" aria-hidden="true">📡</div>
<h1>You're offline</h1>
<p>This page isn't available without a connection. Cached pages will still work. Your changes will sync when you're back online.</p>
<button type="button" onclick="location.reload()">Try again</button>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+75 -97
View File
@@ -1,130 +1,108 @@
// Service Worker for PWA - Caching and offline support
// public/sw.js
// Route Commerce Service Worker
// Two-cache strategy: shell (cache-first) + data (stale-while-revalidate)
const CACHE_NAME = "route-commerce-v1";
const OFFLINE_URL = "/offline";
const SHELL_CACHE = "rc-shell-v3";
const DATA_CACHE = "rc-data-v3";
const OFFLINE_URL = "/offline.html";
const STATIC_ASSETS = [
const SHELL_ASSETS = [
"/",
"/manifest.json",
"/favicon.svg",
"/og-default.jpg",
"/og-default.svg",
"/offline.html",
"/icons/icon-192x192.png",
"/icons/icon-512x512.png",
];
// Install event - cache static assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
);
self.skipWaiting();
});
// Activate event - clean old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
Promise.all([
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.filter((name) => name !== SHELL_CACHE && name !== DATA_CACHE)
.map((name) => caches.delete(name))
)
),
self.clients.claim(),
])
);
})
);
self.clients.claim();
});
// Fetch event - network first, fallback to cache
self.addEventListener("fetch", (event) => {
// Skip non-GET requests
if (event.request.method !== "GET") return;
// Skip API requests
if (event.request.url.includes("/api/")) return;
// Skip cross-origin requests
if (!event.request.url.startsWith(self.location.origin)) return;
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// Navigations → network-first, fall back to cache, fall back to offline page
if (req.mode === "navigate") {
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone response for caching
const responseClone = response.clone();
// Cache successful responses
if (response.status === 200) {
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
fetch(req)
.then((res) => {
const clone = res.clone();
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
return res;
})
.catch(() => {
// Return cached response or offline page
return caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
.catch(() =>
caches.match(req).then((cached) => cached || caches.match(OFFLINE_URL))
)
);
return;
}
// Return offline page for navigation requests
if (event.request.mode === "navigate") {
return caches.match(OFFLINE_URL);
// Static assets → cache-first
if (
url.pathname.startsWith("/_next/static/") ||
url.pathname.startsWith("/icons/") ||
url.pathname.startsWith("/screenshots/") ||
url.pathname.endsWith(".svg") ||
url.pathname.endsWith(".woff2")
) {
event.respondWith(
caches.match(req).then((cached) => {
if (cached) return cached;
return fetch(req).then((res) => {
const clone = res.clone();
if (res.status === 200) {
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
}
return new Response("Network error", { status: 408 });
return res;
});
})
);
});
// Push notification handling
self.addEventListener("push", (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body,
icon: "/icons/icon-192x192.png",
badge: "/icons/badge-72x72.png",
vibrate: [100, 50, 100],
data: {
url: data.url || "/",
},
actions: data.actions || [],
};
event.waitUntil(self.registration.showNotification(data.title, options));
});
// Notification click handling
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/";
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
// Focus existing window or open new one
for (const client of clientList) {
if (client.url === url && "focus" in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(url);
return;
}
// API GETs (read-only data) → stale-while-revalidate
if (url.pathname.startsWith("/api/")) {
event.respondWith(
caches.open(DATA_CACHE).then((cache) =>
cache.match(req).then((cached) => {
const network = fetch(req)
.then((res) => {
if (res.status === 200) cache.put(req, res.clone());
return res;
})
.catch(() => cached);
return cached || network;
})
)
);
return;
}
// Default: try network, fall back to cache
event.respondWith(
fetch(req).catch(() => caches.match(req))
);
});
// Background sync for offline actions
self.addEventListener("sync", (event) => {
if (event.tag === "sync-orders") {
event.waitUntil(syncOrders());
}
});
async function syncOrders() {
// Implement order sync logic
console.log("Syncing orders in background...");
}
-160
View File
@@ -1,160 +0,0 @@
#!/usr/bin/env node
/**
* apply-admin-create-stop.js
*
* Applies the admin_create_stop RPC using ONLY the keys in .env.local.
* You do NOT need Supabase dashboard / SQL editor access.
*
* This is the fix for the "could not find the function public.admin_create_stop(...)"
* error when you click "Add New Stop".
*
* Usage (run from the repo root on a machine that can reach the DB):
* node scripts/apply-admin-create-stop.js
*
* The script connects as the real postgres user (using your SUPABASE_SERVICE_ROLE_KEY
* as the password) and runs the CREATE OR REPLACE + GRANT + NOTIFY.
* This is the only reliable way to create the SECURITY DEFINER function because
* direct REST inserts are blocked by the `block_stops_mutations` policy.
*
* After it prints success:
* - Restart your `npm run dev`
* - "Add New Stop" (AddStopModal / NewStopForm) will now call the RPC successfully.
*/
const { Client } = require("pg");
const fs = require("fs");
const path = require("path");
// Load .env.local (same as the rest of the project)
try {
require("dotenv").config({ path: path.resolve(__dirname, "../.env.local") });
} catch (e) {
// dotenv might not be hoisted in some setups; the keys may already be in process.env
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) {
console.error("❌ Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local");
console.error(" Make sure you are running from the repo root and .env.local exists with the keys.");
process.exit(1);
}
// Derive the direct Postgres URL (same logic as supabase/push-migrations.js)
const projectRef = supabaseUrl.replace("https://", "").split(".")[0];
const pgHost = `db.${projectRef}.supabase.co`;
const dbUrl = `postgresql://postgres:${encodeURIComponent(serviceKey)}@${pgHost}:5432/postgres`;
console.log("🔧 apply-admin-create-stop RPC installer");
console.log(" Project:", projectRef);
console.log(" Using service role key from .env.local (full Postgres access)");
console.log("");
async function main() {
const migrationPath = path.resolve(__dirname, "../supabase/migrations/202_fix_admin_create_stop.sql");
if (!fs.existsSync(migrationPath)) {
console.error("❌ Cannot find migration file:", migrationPath);
console.error(" The file should have been created by the previous fix.");
process.exit(1);
}
const sql = fs.readFileSync(migrationPath, "utf8");
const client = new Client({
connectionString: dbUrl,
ssl: { rejectUnauthorized: false },
// Some environments need a longer timeout
connectionTimeoutMillis: 15000,
});
try {
console.log("⏳ Connecting to Postgres (this uses port 5432 outbound)...");
await client.connect();
console.log("✅ Connected.");
console.log("⏳ Executing 202_fix_admin_create_stop.sql (CREATE OR REPLACE + GRANT + NOTIFY)...");
const result = await client.query(sql);
console.log("✅ Function installed / updated successfully.");
console.log(" Rows affected in last statement:", result.rowCount ?? "(see output above if any)");
console.log("");
console.log("🎉 admin_create_stop is now available via the anon key (SECURITY DEFINER).");
// Quick verification using the anon key over HTTPS (this is what the app uses)
try {
const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const probeBody = {
p_active: false,
p_address: null,
p_brand_id: "00000000-0000-0000-0000-000000000000", // will FK fail, but proves the function exists
p_city: "verify",
p_cutoff_time: null,
p_date: "2099-12-31",
p_location: "verify",
p_state: "TS",
p_time: "10:00",
p_zip: null,
};
const probe = await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/admin_create_stop`, {
method: "POST",
headers: { apikey: anon, "Content-Type": "application/json" },
body: JSON.stringify(probeBody),
});
const txt = await probe.text();
if (probe.status === 404) {
console.log("⚠️ Post-apply probe still got 404 — you may need to wait a few seconds or the NOTIFY didn't take.");
} else if (/foreign key|violates|brand_id/i.test(txt)) {
console.log("✅ Post-apply probe: function exists (got expected FK error on the zero-UUID brand — this is success).");
} else {
console.log("✅ Post-apply probe returned:", txt.slice(0, 200));
}
} catch (e) {
console.log(" (verification fetch skipped:", e.message, ")");
}
console.log("");
console.log("Next:");
console.log(" • Restart your dev server (`npm run dev`) if it was already running.");
console.log(" • Go to the stops admin page and try \"Add New Stop\".");
console.log(" • (Optional but recommended) node scripts/verify-stop-fns.js");
} catch (err) {
const msg = (err && err.message) ? err.message : String(err);
console.error("❌ Failed to apply the function.");
console.error(" Error:", msg.slice(0, 500));
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo|network is unreachable|ECONNREFUSED|timeout/i.test(msg)) {
console.log("");
console.log("💡 Your current environment cannot reach the database on port 5432.");
console.log(" This is common inside some containers/CI sandboxes.");
console.log("");
console.log(" Copy and run ONE of the following on a *normal* laptop/terminal that has");
console.log(" outbound access to Supabase Postgres (most developer machines can reach it):");
console.log("");
console.log(" Option A (easiest, uses exactly the same code as this script):");
console.log(" npm run migrate:one 202");
console.log("");
console.log(" Option B (psql):");
console.log(" # Build the URL using the key from your .env.local (same as this script does):");
console.log(" # psql \"postgresql://postgres:PUT_YOUR_SERVICE_ROLE_KEY_HERE@db." + projectRef + ".supabase.co:5432/postgres\" \\");
console.log(" # -f supabase/migrations/202_fix_admin_create_stop.sql");
console.log("");
console.log(" Option C (Supabase CLI):");
console.log(" supabase db push --db-url \"postgresql://postgres:YOUR_SERVICE_ROLE_KEY@db." + projectRef + ".supabase.co:5432/postgres\" \\");
console.log(" supabase/migrations/202_fix_admin_create_stop.sql");
console.log("");
console.log(" After the command succeeds you will see the NOTIFY and the function will exist.");
console.log(" Then restart `npm run dev` and \"Add New Stop\" will stop showing the function-not-found error.");
}
process.exit(1);
} finally {
try { await client.end(); } catch {}
}
}
main().catch((e) => {
console.error("Unexpected crash:", e);
process.exit(1);
});
-49
View File
@@ -1,49 +0,0 @@
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const headers = {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
"Content-Type": "application/json",
};
async function rpc(name, body = {}) {
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
method: "POST", headers, body: JSON.stringify(body),
});
return { status: r.status, text: (await r.text()).slice(0, 500) };
}
(async () => {
// 1. Try calling admin_create_stop with service role
console.log("=== 1. Call admin_create_stop (service role) ===");
console.log(await rpc("admin_create_stop", { p_brand_id: null }));
// 2. Try with body
console.log("\n=== 2. Call admin_create_stop with stub body ===");
console.log(await rpc("admin_create_stop", {
p_brand_id: "00000000-0000-0000-0000-000000000000",
p_city: "test", p_state: "CO", p_location: "x", p_date: "2025-01-01", p_time: "08:00"
}));
// 3. Try with a name that obviously doesn't exist to see the error format
console.log("\n=== 3. Call a non-existent function (control) ===");
console.log(await rpc("definitely_does_not_exist_xyz", {}));
// 4. Use the PostgREST introspection — query information_schema via rpc
// PostgREST exposes pg_proc through /rest/v1/ for system tables is not allowed
// But we can use the OpenAPI spec endpoint
console.log("\n=== 4. OpenAPI spec — looking for admin_create_stop ===");
const openApi = await fetch(`${url}/rest/v1/`, { headers: { apikey: anonKey, Authorization: `Bearer ${anonKey}` } });
const text = await openApi.text();
const matches = text.match(/admin_create_stop[a-z_]*/g) || [];
console.log("Matches in OpenAPI:", matches);
// 5. Get all function names from OpenAPI
const funcMatches = [...text.matchAll(/"([a-z][a-z0-9_]*)\s*\{/g)].map(m => m[1]);
const stopFns = funcMatches.filter(n => n.includes("stop"));
console.log("\nStop-related functions in OpenAPI:", stopFns);
})();
-52
View File
@@ -1,52 +0,0 @@
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const { Client } = require("pg");
const dns = require("dns");
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const projectRef = url.replace("https://", "").split(".")[0];
const candidates = [
`db.${projectRef}.supabase.co`,
`aws-0-us-east-1.pooler.supabase.com`,
`aws-0-us-west-1.pooler.supabase.com`,
];
(async () => {
for (const host of candidates) {
try {
const ip = await new Promise((resolve, reject) => {
dns.resolve4(host, (err, addrs) => err ? reject(err) : resolve(addrs));
});
console.log(`${host} -> ${ip.join(", ")}`);
} catch (e) {
console.log(`${host} -> DNS FAIL: ${e.code}`);
}
}
// Try direct DB with port 6543 (pooler) using supabase format
// Username pattern: postgres.<project-ref>
const client = new Client({
host: "aws-0-us-east-1.pooler.supabase.com",
port: 6543, database: "postgres",
user: `postgres.${projectRef}`,
password: process.env.SUPABASE_SERVICE_ROLE_KEY,
ssl: { rejectUnauthorized: false },
});
try {
await client.connect();
const r = await client.query(`
SELECT p.proname,
pg_get_function_identity_arguments(p.oid) AS id_args,
p.prosecdef AS secdef
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
WHERE n.nspname='public' AND p.proname IN
('admin_create_stop','admin_create_stops_batch','delete_stop')
ORDER BY p.proname;
`);
console.log("\nFUNCTIONS IN DB:");
console.log(JSON.stringify(r.rows, null, 2));
} catch (e) {
console.error("POOLER ERR:", e.message);
} finally {
try { await client.end(); } catch {}
}
})();
-44
View File
@@ -1,44 +0,0 @@
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const { Client } = require("pg");
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const projectRef = url.replace("https://", "").split(".")[0];
const pw = process.env.SUPABASE_SERVICE_ROLE_KEY;
const tries = [
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: `postgres.${projectRef}`, database: "postgres" },
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 5432, user: `postgres.${projectRef}`, database: "postgres" },
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: "postgres", database: "postgres" },
];
(async () => {
for (const cfg of tries) {
const client = new Client({ ...cfg, password: pw, ssl: { rejectUnauthorized: false } });
try {
await client.connect();
const r = await client.query(`
SELECT p.proname,
pg_get_function_identity_arguments(p.oid) AS id_args,
p.prosecdef AS secdef
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
WHERE n.nspname='public' AND p.proname IN
('admin_create_stop','admin_create_stops_batch','delete_stop')
ORDER BY p.proname;
`);
console.log(`\n${cfg.host}:${cfg.port} user=${cfg.user}`);
console.log(JSON.stringify(r.rows, null, 2));
// Also check if migration tracking table exists
const trk = await client.query(`
SELECT version, name FROM supabase_migrations.schema_migrations
WHERE version >= 145 ORDER BY version;
`).catch(() => ({ rows: [] }));
console.log("Recent migrations applied:", trk.rows);
await client.end();
return;
} catch (e) {
console.log(`${cfg.host}:${cfg.port} user=${cfg.user}${e.message}`);
try { await client.end(); } catch {}
}
}
})();
-61
View File
@@ -1,61 +0,0 @@
/**
* Create an admin user in Neon Auth via direct HTTP call.
* Run: npx tsx scripts/create-admin-user.ts [email] [password] [name]
*
* Makes a direct HTTP request to the Neon Auth API so we don't need
* a Next.js request context, then updates email_verified in the DB.
*/
import { config } from "dotenv";
config({ path: ".env.local" });
import pg from "pg";
const { Pool } = pg;
async function main() {
const email = process.argv[2] ?? "admin@example.com";
const password = process.argv[3] ?? "Admin1234!";
const name = process.argv[4] ?? "Admin";
const baseUrl = process.env.NEON_AUTH_BASE_URL!;
console.log(`Creating user: ${email}`);
const res = await fetch(`${baseUrl}/sign-up/email`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Origin": "http://localhost:4000",
"x-neon-auth-proxy": "node",
},
body: JSON.stringify({ email, password, name, callbackURL: "/admin" }),
});
const data = await res.json().catch(() => ({}));
console.log(`Sign-up status: ${res.status}`);
if (!res.ok) {
console.error("Failed to create user:", JSON.stringify(data));
process.exit(1);
}
const userId = data.user?.id;
console.log("User created:", data.user?.email, "| ID:", userId);
// Mark email verified in DB so they can log in immediately
if (userId) {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
await pool.query(
"UPDATE neon_auth.user SET email_verified = true WHERE id = $1",
[userId]
);
console.log("Email verified in DB.");
} finally {
await pool.end();
}
}
console.log(`\nLog in at http://localhost:4000/login with ${email} / ${password}`);
}
main();
-99
View File
@@ -1,99 +0,0 @@
#!/bin/bash
# End-to-end validation test for the local Postgres + PostgREST + MinIO + Next.js stack
# Exit 0 = all green, exit 1 = at least one failure.
set -e
API="http://localhost:3001"
WEB="http://localhost:4000"
pass=0
fail=0
declare -a FAILURES
check() {
local name="$1" url="$2" expected="${3:-200}" cookies="${4:-}"
local cmd="curl -s -o /dev/null -w '%{http_code}'"
if [ -n "$cookies" ]; then cmd="$cmd -b \"$cookies\""; fi
local code=$(eval "$cmd $url")
if [ "$code" = "$expected" ]; then
echo " PASS $name ($code)"
pass=$((pass+1))
else
echo " FAIL $name expected=$expected got=$code url=$url"
fail=$((fail+1))
FAILURES+=("$name")
fi
}
echo "=== Postgres connection ==="
PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT 'ok';" >/dev/null
echo " PASS postgres responds"
pass=$((pass+1))
echo "=== DB integrity ==="
TABLE_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';")
[ "$TABLE_COUNT" -ge 65 ] && echo " PASS $TABLE_COUNT public tables (>=65)" && pass=$((pass+1)) || { echo " FAIL $TABLE_COUNT tables"; fail=$((fail+1)); }
FN_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname='public';")
[ "$FN_COUNT" -ge 250 ] && echo " PASS $FN_COUNT functions (>=250)" && pass=$((pass+1)) || { echo " FAIL $FN_COUNT functions"; fail=$((fail+1)); }
BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands;")
[ "$BRANDS" -ge 2 ] && echo " PASS $BRANDS brands" && pass=$((pass+1)) || { echo " FAIL $BRANDS brands"; fail=$((fail+1)); }
STOPS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM stops WHERE active=true AND deleted_at IS NULL;")
[ "$STOPS" -ge 200 ] && echo " PASS $STOPS active stops" && pass=$((pass+1)) || { echo " FAIL $STOPS stops"; fail=$((fail+1)); }
# No test brands
TEST_BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands WHERE slug IN ('sunrise-farms','green-valley','orchard-fresh');")
[ "$TEST_BRANDS" = "0" ] && echo " PASS no test brands" && pass=$((pass+1)) || { echo " FAIL test brands found"; fail=$((fail+1)); }
echo "=== Tuxedo Corn data ==="
PHONE=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT phone FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de';")
[ "$PHONE" = "970-323-6874" ] && echo " PASS phone=$PHONE" && pass=$((pass+1)) || { echo " FAIL phone=$PHONE (expected 970-323-6874)"; fail=$((fail+1)); }
LOGOS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de' AND logo_url LIKE '/storage/%';")
[ "$LOGOS" = "1" ] && echo " PASS logo_url is /storage/ path" && pass=$((pass+1)) || { echo " FAIL logo_url not /storage/"; fail=$((fail+1)); }
echo "=== PostgREST ==="
check "GET /" "$API/"
check "GET /brands" "$API/brands?select=id,name,slug&order=name"
check "GET /brands?slug=eq.tuxedo" "$API/brands?slug=eq.tuxedo&select=*"
check "GET /stops" "$API/stops?select=id,city&limit=5"
check "GET /products" "$API/products?select=id,name&limit=5"
check "POST rpc get_brand_settings_by_slug" "$API/rpc/get_brand_settings_by_slug" 200 \
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
check "POST rpc get_brand_plan_info" "$API/rpc/get_brand_plan_info" 200 \
"-X POST -H Content-Type:application/json -d {\"p_brand_id\":\"64294306-5f42-463d-a5e8-2ad6c81a96de\"}"
check "POST rpc get_public_stops_for_brand" "$API/rpc/get_public_stops_for_brand" 200 \
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
check "GET /admin_users with brand join" "$API/admin_users?select=id,user_id,role,brand_id,brands(name)&limit=5"
check "GET /stops with brand join" "$API/stops?select=id,city,brand_id,brands(name)&limit=5"
echo "=== MinIO ==="
check "Storage logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
check "Storage olathe-sweet-logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
check "Storage olathe-sweet-logo-dark.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"
echo "=== Public storefronts ==="
check "GET /" "$WEB/"
check "GET /tuxedo" "$WEB/tuxedo"
check "GET /tuxedo/about" "$WEB/tuxedo/about"
check "GET /tuxedo/stops" "$WEB/tuxedo/stops"
check "GET /indian-river-direct" "$WEB/indian-river-direct"
check "GET /login" "$WEB/login"
echo "=== Admin pages (dev_session=platform_admin) ==="
for p in /admin /admin/products /admin/stops /admin/orders /admin/users /admin/settings /admin/settings/billing /admin/settings/apps /admin/settings/payments /admin/communications /admin/communications/compose /admin/time-tracking /admin/wholesale /admin/water-log /admin/analytics /admin/reports; do
check "GET $p" "$WEB$p" 200 "dev_session=platform_admin"
done
echo ""
echo "=== Summary ==="
echo " PASS: $pass"
echo " FAIL: $fail"
if [ $fail -gt 0 ]; then
echo " Failures:"
for f in "${FAILURES[@]}"; do echo " - $f"; done
exit 1
fi
echo " ALL GREEN"
+44
View File
@@ -0,0 +1,44 @@
// scripts/generate-pwa-icons.js
// Generates the full PWA icon set from public/favicon.svg
// Run: node scripts/generate-pwa-icons.js
const fs = require("fs");
const path = require("path");
const sharp = require("sharp");
const SIZES = [72, 96, 128, 144, 152, 192, 384, 512];
const OUT_DIR = path.join(__dirname, "..", "public", "icons");
const SVG_PATH = path.join(__dirname, "..", "public", "favicon.svg");
const svg = fs.readFileSync(SVG_PATH);
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
for (const size of SIZES) {
await sharp(svg).resize(size, size).png().toFile(path.join(OUT_DIR, `icon-${size}x${size}.png`));
console.log(`✓ icon-${size}x${size}.png`);
}
// Maskable: same icon, with 20% safe-area padding baked in
await sharp(svg).resize(410, 410).extend({
top: 51, bottom: 51, left: 51, right: 51,
background: { r: 22, g: 77, b: 46, alpha: 1 },
}).png().toFile(path.join(OUT_DIR, "icon-maskable-512x512.png"));
console.log("✓ icon-maskable-512x512.png");
// Badge
await sharp(svg).resize(72, 72).png().toFile(path.join(OUT_DIR, "badge-72x72.png"));
console.log("✓ badge-72x72.png");
// Shortcuts (same icon, 96x96)
for (const name of ["orders-shortcut", "products-shortcut", "stops-shortcut"]) {
await sharp(svg).resize(96, 96).png().toFile(path.join(OUT_DIR, `${name}.png`));
console.log(`${name}.png`);
}
// Apple touch icons
for (const [name, size] of [["apple-touch-icon", 180], ["apple-touch-icon-167", 167], ["apple-touch-icon-152", 152]]) {
await sharp(svg).resize(size, size).png().toFile(path.join(__dirname, "..", "public", `${name}.png`));
console.log(`${name}.png`);
}
// favicon.ico (32x32)
await sharp(svg).resize(32, 32).png().toFile(path.join(__dirname, "..", "public", "favicon.ico"));
console.log("✓ favicon.ico");
}
main().catch((err) => { console.error(err); process.exit(1); });
+39
View File
@@ -0,0 +1,39 @@
// scripts/generate-pwa-screenshots.js
const fs = require("fs");
const path = require("path");
const sharp = require("sharp");
const OUT_DIR = path.join(__dirname, "..", "public", "screenshots");
fs.mkdirSync(OUT_DIR, { recursive: true });
async function main() {
// Desktop dashboard placeholder
await sharp({
create: { width: 1280, height: 720, channels: 3, background: { r: 250, g: 248, b: 245 } }
})
.composite([{
input: Buffer.from(`<svg width="1280" height="720" xmlns="http://www.w3.org/2000/svg">
<text x="640" y="360" font-family="serif" font-size="48" fill="#1a4d2e" text-anchor="middle">Route Commerce</text>
</svg>`),
top: 0, left: 0,
}])
.png()
.toFile(path.join(OUT_DIR, "dashboard.png"));
console.log("✓ dashboard.png");
// Mobile storefront placeholder
await sharp({
create: { width: 390, height: 844, channels: 3, background: { r: 250, g: 248, b: 245 } }
})
.composite([{
input: Buffer.from(`<svg width="390" height="844" xmlns="http://www.w3.org/2000/svg">
<text x="195" y="422" font-family="serif" font-size="32" fill="#1a4d2e" text-anchor="middle">Route Commerce</text>
</svg>`),
top: 0, left: 0,
}])
.png()
.toFile(path.join(OUT_DIR, "mobile-storefront.png"));
console.log("✓ mobile-storefront.png");
}
main().catch(console.error);
+447
View File
@@ -0,0 +1,447 @@
// dotenv load temporarily disabled so shell-provided DATABASE_URL wins for this run
// import { config } from "dotenv";
// config({ path: ".env.local" });
import { Pool } from "pg";
import ExcelJS from "exceljs";
import * as fs from "fs";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const YEAR = 2026;
const MONTH_MAP: Record<string, string> = {
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
};
interface ParsedStop {
week: string;
region: string;
date: string;
day: string;
city: string;
state: string;
location: string;
time: string;
timeRange: string;
truck: string;
statusText: string;
notes: string;
address: string | null;
phone: string | null;
contact: string | null;
}
function parseExcelDate(s: unknown): string | null {
if (!s) return null;
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
if (!m) return null;
const mm = MONTH_MAP[m[1]];
if (!mm) return null;
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
}
function parseTimeRange(s: unknown): string {
if (!s) return "";
let c = String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
const m = /^(\d{1,2}:\d{2}\s*[AP]M)/i.exec(c);
// Prefer full range for display (e.g. "10:00 AM 1:00 PM"); fall back to start only
return c || (m ? m[1].toUpperCase().replace(" ", " ") : "");
}
function splitCityState(s: unknown): [string, string] {
if (!s) return ["", ""];
const parts = String(s).split(",").map((p) => p.trim());
if (parts.length === 1) return [parts[0], ""];
return [parts[0], parts[1]];
}
function isWeekHeader(cells: string[]): boolean {
return /^Wk\s/i.test(cells[0] || "") && (cells[3] || "").trim() === "";
}
function isOffRow(cells: string[]): boolean {
const d = (cells[3] || "").trim();
return /OFF|Cross-?Dock/i.test(d);
}
function isDataRow(cells: string[]): boolean {
const day = (cells[3] || "").trim();
const city = (cells[4] || "").trim();
return !!(day && city && city.includes(","));
}
function slugify(s: string): string {
let out = (s || "").toLowerCase();
out = out.replace(/[^a-z0-9]+/g, "-");
return out.replace(/^-+|-+$/g, "");
}
async function loadStops(xlsxPath: string): Promise<{ stops: ParsedStop[]; skipped: Record<string, number>; dirCount: number; locations: ParsedLocation[] }> {
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(xlsxPath);
const schedule = wb.getWorksheet("Full Schedule");
const directory = wb.getWorksheet("Stop Directory");
if (!schedule) throw new Error("Missing 'Full Schedule' sheet");
// Build directory lookup for address enrichment: "T1|host lower" -> info
const dirMap = new Map<string, { address: string | null; phone: string | null; contact: string | null; state: string }>();
if (directory) {
for (let r = 2; r <= directory.rowCount; r++) {
const row = directory.getRow(r);
const truck = String(row.getCell(1).value || "").trim();
const host = String(row.getCell(4).value || "").trim().toLowerCase();
if (!truck || !host) continue;
dirMap.set(`${truck}|${host}`, {
address: String(row.getCell(5).value || "").trim() || null,
phone: String(row.getCell(6).value || "").trim() || null,
contact: String(row.getCell(7).value || "").trim() || null,
state: String(row.getCell(3).value || "").trim(),
});
}
}
const stops: ParsedStop[] = [];
const skipped: Record<string, number> = { weekHeader: 0, off: 0, invalid: 0, noDate: 0 };
for (let r = 4; r <= schedule.rowCount; r++) {
const row = schedule.getRow(r);
const cells = Array.from({ length: 10 }, (_, i) => String(row.getCell(i + 1).value ?? "").trim());
if (isWeekHeader(cells)) {
skipped.weekHeader++;
continue;
}
if (isOffRow(cells)) {
skipped.off++;
continue;
}
if (!isDataRow(cells)) {
skipped.invalid++;
continue;
}
const [wk, region, dateText, day, cityState, host, time, truck, status, notes] = cells;
const dateIso = parseExcelDate(dateText);
if (!dateIso) {
skipped.noDate++;
continue;
}
const [city, state] = splitCityState(cityState);
if (!city) {
skipped.invalid++;
continue;
}
const key = `${truck}|${host.toLowerCase()}`;
const d = dirMap.get(key);
stops.push({
week: wk,
region,
date: dateIso,
day,
city,
state: state || (d?.state || ""),
location: host,
time: parseTimeRange(time),
timeRange: time,
truck,
statusText: status,
notes: notes || "",
address: d?.address || null,
phone: d?.phone || null,
contact: d?.contact || null,
});
}
return { stops, skipped, dirCount: dirMap.size, locations: loadLocations(directory) };
}
export type ParsedLocation = {
name: string;
address: string | null;
city: string;
state: string;
phone: string | null;
contactName: string | null;
notes: string | null;
};
function loadLocations(directory: ExcelJS.Worksheet | undefined): ParsedLocation[] {
if (!directory) return [];
const byKey = new Map<string, ParsedLocation>();
for (let r = 2; r <= directory.rowCount; r++) {
const row = directory.getRow(r);
const truck = String(row.getCell(1).value || "").trim();
const city = String(row.getCell(2).value || "").trim();
const state = String(row.getCell(3).value || "").trim();
const host = String(row.getCell(4).value || "").trim();
const address = String(row.getCell(5).value || "").trim() || null;
let phone = String(row.getCell(6).value || "").trim() || null;
const contact = String(row.getCell(7).value || "").trim() || null;
// notes column is 12
let notes = String(row.getCell(12).value || "").trim() || null;
if (!host || !city) continue;
// If phone field contains TBD email, move it to notes
if (phone && /@/.test(phone) && (!notes || notes.length < 10)) {
notes = phone;
phone = null;
}
const key = `${host.toLowerCase()}|${city.toLowerCase()}|${state.toLowerCase()}`;
if (!byKey.has(key)) {
byKey.set(key, {
name: host,
address,
city,
state,
phone,
contactName: contact || null,
notes,
});
}
}
return Array.from(byKey.values());
}
function toLocationRpcRow(l: ParsedLocation) {
return {
name: l.name,
address: l.address,
city: l.city,
state: l.state,
zip: null,
phone: l.phone,
contact_name: l.contactName,
contact_email: null,
notes: l.notes,
active: true,
};
}
function toRpcRow(s: ParsedStop) {
// Date format matches the python seed script expectation for the RPC
const dateWithTz = `${s.date} 00:00:00+00`;
const combinedNotes = [
s.notes,
s.truck ? `Truck: ${s.truck}` : "",
s.statusText ? `Status: ${s.statusText}` : "",
s.phone ? `Phone: ${s.phone}` : "",
s.contact ? `Contact: ${s.contact}` : "",
]
.filter(Boolean)
.join(" | ");
return {
city: s.city,
state: s.state,
location: s.location,
date: dateWithTz,
time: s.time || s.timeRange || "",
address: s.address,
zip: null,
cutoff_time: null,
// active=true makes them visible on the public /tuxedo/stops storefront immediately
active: true,
// forward notes when the RPC supports it (enriched with truck/status/contact)
notes: combinedNotes || null,
};
}
async function main() {
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run") || args.includes("-n");
let xlsxPath = "./Tuxedo_Corn_2026_Tour_Schedule-3.xlsx";
const xArg = args.findIndex((a) => a === "--xlsx" || a === "-x");
if (xArg !== -1 && args[xArg + 1]) xlsxPath = args[xArg + 1];
const doClean = !args.includes("--no-clean");
const emitSql = args.includes("--emit-sql") || args.includes("--sql");
console.log(`[import-tuxedo-stops] xlsx=${xlsxPath} dryRun=${dryRun} clean=${doClean} emitSql=${emitSql}`);
const { stops, skipped, dirCount, locations: parsedLocations } = await loadStops(xlsxPath);
console.log(`\nParsed ${stops.length} stops + ${parsedLocations.length} unique locations`);
console.log(`Skipped: week-headers=${skipped.weekHeader}, off/cross-dock=${skipped.off}, invalid=${skipped.invalid}, no-date=${skipped.noDate}`);
console.log(`Stop Directory raw entries: ${dirCount}\n`);
if (!stops.length) {
console.error("No stops to insert. Check the xlsx path and filters.");
process.exit(1);
}
// Show samples
console.log("Sample (first 3):");
stops.slice(0, 3).forEach((s) => {
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
if (s.address) console.log(` addr: ${s.address}`);
});
console.log("\nSample (last 3):");
stops.slice(-3).forEach((s) => {
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
});
// By week/region/truck
const byWeek: Record<string, number> = {};
const byRegion: Record<string, number> = {};
const byTruck: Record<string, number> = {};
for (const s of stops) {
byWeek[s.week] = (byWeek[s.week] || 0) + 1;
byRegion[s.region] = (byRegion[s.region] || 0) + 1;
byTruck[s.truck] = (byTruck[s.truck] || 0) + 1;
}
console.log("\nBy week:", Object.fromEntries(Object.entries(byWeek).sort(([a], [b]) => Number(a) - Number(b))));
console.log("By region:", byRegion);
console.log("By truck:", byTruck);
const dates = [...stops.map((s) => s.date)].sort();
console.log(`\nDate range: ${dates[0]}${dates[dates.length - 1]}`);
if (emitSql) {
const BATCH = 50;
let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`;
sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`;
sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`;
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
sql += `BEGIN;\n\n`;
// Locations first (master directory)
if (parsedLocations.length > 0) {
sql += `-- ===== LOCATIONS (Stop Directory master records) =====\n`;
sql += `DELETE FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}';\n\n`;
const locPayload = parsedLocations.map(toLocationRpcRow);
const locJson = JSON.stringify(locPayload);
sql += `-- ${parsedLocations.length} unique locations from Stop Directory\n`;
sql += `SELECT admin_create_locations_batch('${TUXEDO_BRAND_ID}'::uuid, $$${locJson}$$::jsonb);\n\n`;
}
// Stops (dated instances)
sql += `-- ===== STOPS (2026 tour schedule) =====\n`;
sql += `-- Clear prior 2026 tour stops for brand (date-scoped)\n`;
sql += `DELETE FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
for (let i = 0; i < stops.length; i += BATCH) {
const batchStops = stops.slice(i, i + BATCH);
const payload = batchStops.map(toRpcRow);
const payloadJson = JSON.stringify(payload);
const bnum = Math.floor(i / BATCH) + 1;
sql += `-- stops batch ${bnum}/${Math.ceil(stops.length / BATCH)} (${payload.length})\n`;
sql += `SELECT admin_create_stops_batch('${TUXEDO_BRAND_ID}'::uuid, $$${payloadJson}$$::jsonb);\n\n`;
}
sql += `-- Make stops visible (RPC inserts as draft + active=true in payload)\n`;
sql += `UPDATE stops SET status = 'active' WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
sql += `COMMIT;\n`;
const outPath = "db/seeds/2026-tuxedo-tour-stops.sql";
fs.writeFileSync(outPath, sql, "utf8");
console.log(`\nWrote ${outPath} (locations: ${parsedLocations.length}, stops: ${stops.length}).`);
}
if (dryRun) {
const stopBatches = Math.ceil(stops.length / 50);
console.log(`\n[DRY RUN] Would load for brand:\n - ${parsedLocations.length} locations (full replace)\n - DELETE date-scoped 2026 stops then INSERT ${stops.length} stops in ${stopBatches} batch(es)\n via admin_*_batch RPCs.`);
return;
}
const pool = new Pool({
connectionString:
process.env.DATABASE_ADMIN_URL ??
process.env.DATABASE_URL ??
"postgres://postgres:postgres@localhost:5432/route_commerce",
});
const client = await pool.connect();
try {
await client.query("BEGIN");
if (doClean) {
const del = await client.query(
`DELETE FROM stops WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
[TUXEDO_BRAND_ID],
);
console.log(`\nCleared ${del.rowCount} prior 2026-dated stops for Tuxedo brand.`);
}
// Locations (from Stop Directory) — full replace for the brand from this xlsx
if (parsedLocations.length > 0) {
const locDel = await client.query(
`DELETE FROM locations WHERE brand_id = $1`,
[TUXEDO_BRAND_ID],
);
console.log(`Cleared ${locDel.rowCount} previous locations for Tuxedo brand.`);
const LOC_BATCH = 100; // locations are fewer and smaller
let locTotal = 0;
const locBatches = Math.ceil(parsedLocations.length / LOC_BATCH);
for (let i = 0; i < parsedLocations.length; i += LOC_BATCH) {
const batchLocs = parsedLocations.slice(i, i + LOC_BATCH);
const payload = batchLocs.map(toLocationRpcRow);
const bnum = Math.floor(i / LOC_BATCH) + 1;
process.stdout.write(` Locations batch ${bnum}/${locBatches} (${payload.length}) via admin_create_locations_batch... `);
try {
await client.query(
"SELECT admin_create_locations_batch($1::uuid, $2::jsonb)",
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
);
locTotal += payload.length;
console.log("OK");
} catch (e: any) {
console.log("FAIL");
throw e;
}
}
console.log(` Inserted ${locTotal} locations.`);
}
const BATCH = 50;
let total = 0;
const batches = Math.ceil(stops.length / BATCH);
for (let i = 0; i < stops.length; i += BATCH) {
const batchStops = stops.slice(i, i + BATCH);
const payload = batchStops.map(toRpcRow);
const bnum = Math.floor(i / BATCH) + 1;
process.stdout.write(` Batch ${bnum}/${batches} (${payload.length}) via admin_create_stops_batch... `);
try {
await client.query(
"SELECT admin_create_stops_batch($1::uuid, $2::jsonb)",
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
);
total += payload.length;
console.log("OK");
} catch (e: any) {
console.log("FAIL");
throw e;
}
}
// RPC inserts with status='draft' (per prior script); flip to active for public visibility.
// Scope by the tour date range so we only touch rows from this import.
if (total > 0) {
const pub = await client.query(
`UPDATE stops SET status = 'active' WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
[TUXEDO_BRAND_ID],
);
console.log(`\nPublished (status=active): ${pub.rowCount} stops.`);
}
await client.query("COMMIT");
console.log(`\nDone. Inserted ${parsedLocations.length} locations + ${total} stops for Tuxedo Corn 2026 tour (brand ${TUXEDO_BRAND_ID}).`);
console.log("Locations are the master directory; stops are the dated schedule instances.");
console.log("They should now appear on /tuxedo/stops and in admin locations UI.");
} catch (err) {
await client.query("ROLLBACK");
console.error("\nInsert failed (rolled back):", err);
process.exitCode = 1;
} finally {
client.release();
await pool.end();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
-262
View File
@@ -1,262 +0,0 @@
/**
* import-woo-to-route.ts - 2026 Season Import
*
* Reads Tuxedo_Corn_2026_Tour_Schedule-2.xlsx (Full Tour Schedule sheet) and produces:
* products.csv - 1 core pickup product
* stops.csv - 164 active stops
*
* Usage:
* npx tsx scripts/import-woo-to-route.ts \
* --xlsx "/path/to/Tuxedo_Corn_2026_Tour_Schedule-2.xlsx" \
* --brand 64294306-5f42-463d-a5e8-2ad6c81a96de \
* --output ./import-output
*/
import * as fs from "fs";
import * as path from "path";
import { parseArgs } from "util";
import ExcelJS from "exceljs";
// Types
interface ProductOutput {
name: string;
price: number;
type: "Pickup" | "Shipping" | "Pickup & Shipping";
description: string;
active: boolean;
image_url: string;
is_taxable: boolean;
}
interface StopOutput {
city: string;
state: string;
location: string;
date: string;
time: string;
address: string;
notes: string;
}
function slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
async function main() {
const args = parseArgs({
options: {
xlsx: { type: "string" },
brand: { type: "string" },
output: { type: "string", default: "./import-output" },
},
});
const xlsxPath = args.values.xlsx as string;
const brandId = args.values.brand as string;
const outputDir = args.values.output as string;
if (!xlsxPath || !brandId) {
console.error(
"Usage: npx tsx scripts/import-woo-to-route.ts --xlsx <path> --brand <uuid> [--output <dir>]"
);
process.exit(1);
}
if (!fs.existsSync(xlsxPath)) {
console.error("File not found: " + xlsxPath);
process.exit(1);
}
// Read Excel
// Row 0=title, Row1=subtitle, Row2=color-key, Row3=column-headers, Row4+=data
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(xlsxPath);
const sheet = wb.getWorksheet(1);
if (!sheet) {
console.error("No worksheet found in file");
process.exit(1);
}
const allRows: (string | null | undefined)[][] = [];
sheet.eachRow((row) => {
allRows.push(row.values as (string | null | undefined)[]);
});
const colHeaders = (allRows[3] as string[]).map((h: unknown) => String(h ?? "").trim());
console.log("Column headers:", colHeaders);
const ci = (col: string) => colHeaders.indexOf(col);
const wkIdx = ci("Wk"), typeIdx = ci("Type"), datesIdx = ci("Dates"), dayIdx = ci("Day");
const cityIdx = ci("City / Location"), hostIdx = ci("Host / Venue");
const timeIdx = ci("Time"), truckIdx = ci("Truck"), statusIdx = ci("Status"), notesIdx = ci("Notes");
interface ScheduleRow {
wk: number; type: string; dates: string; day: string;
city: string; host: string; time: string; truck: string; status: string; notes: string;
}
const scheduleRows: ScheduleRow[] = [];
let currentWeek: { wk: number; type: string; dates: string; day: string } | null = null;
for (let i = 4; i < allRows.length; i++) {
const row = allRows[i] as (string | null | undefined)[];
if (!row) continue;
const wkVal = row[wkIdx];
const Day = String(row[dayIdx] ?? "").trim();
const City_Location = String(row[cityIdx] ?? "").trim();
const Status = String(row[statusIdx] ?? "").trim();
if (wkVal !== null && wkVal !== undefined && String(wkVal).trim() !== "") {
currentWeek = {
wk: Number(wkVal),
type: String(row[typeIdx] ?? "").trim(),
dates: String(row[datesIdx] ?? "").trim(),
day: Day,
};
}
if (!currentWeek) continue;
if (Status === "Off day") continue;
if (!City_Location) continue;
scheduleRows.push({
wk: currentWeek.wk, type: currentWeek.type, dates: currentWeek.dates,
day: Day || currentWeek.day,
city: City_Location,
host: String(row[hostIdx] ?? "").trim(),
time: String(row[timeIdx] ?? "").trim(),
truck: String(row[truckIdx] ?? "").trim(),
status: Status,
notes: String(row[notesIdx] ?? "").trim(),
});
}
const ACTIVE_STATUSES = ["✓ Confirmed", "🔴 Pre-sale live"];
const activeRows = scheduleRows.filter((r) => ACTIVE_STATUSES.includes(r.status));
console.log("\nSchedule rows: " + scheduleRows.length + " total | " + activeRows.length + " active\n");
// PRODUCTS -- one core product for all stops
const products: ProductOutput[] = [
{
name: "Olathe Sweet Corn -- 24 Ear Box",
price: 25,
type: "Pickup",
description:
"Fresh Olathe Sweet Corn pickup box. Add to cart, then choose your pickup stop at checkout. " +
"Available July-September 2026 at stops across Colorado, Wyoming, and New Mexico. " +
"See tuxedocorn.com for full schedule.",
active: true,
image_url:
"https://tuxedocorn.com/wp-content/uploads/2024/06/110260317_3494904527187819_6983832261179080791_n-1.jpg",
is_taxable: true,
},
];
console.log("[products] 1 core product: \"" + products[0].name + "\" at $" + products[0].price + "\n");
// STOPS -- one row per active schedule entry
const stops: StopOutput[] = [];
const seenStopKeys = new Set<string>();
for (const row of activeRows) {
if (!row.city) continue;
// Parse "City ST" or "City, ST" -> city + state
const cityRaw = row.city.replace(/--/g, "-").trim();
let city = cityRaw, state = "CO";
if (cityRaw.includes(",")) {
const parts = cityRaw.split(",").map((s: string) => s.trim());
const lastPart = parts[parts.length - 1] ?? "";
if (/^[A-Z]{2}$/.test(lastPart)) { city = parts.slice(0, -1).join(", ").trim(); state = lastPart; }
else city = parts.join(", ");
} else {
const tokens = cityRaw.split(/\s+/);
const lastToken = tokens[tokens.length - 1] ?? "";
if (/^[A-Z]{2}$/.test(lastToken)) { state = lastToken; city = tokens.slice(0, -1).join(" "); }
}
const stopKey = slugify(city + "-" + state + "-" + row.dates + "-" + row.time);
if (seenStopKeys.has(stopKey)) continue;
seenStopKeys.add(stopKey);
// Human-readable stop label = "Store (Time) | Truck T1"
const stopName = row.host + " (" + row.time + ")" + (row.truck ? " | Truck " + row.truck : "");
stops.push({
city,
state,
location: stopName,
date: row.dates, // week range -- set actual date in /admin/stops after import
time: row.time,
address: row.city + " | " + row.host,
notes: row.day + (row.notes ? " | " + row.notes : ""),
});
}
console.log("[stops] " + stops.length + " stops written\n");
// Show sample by week
const byWeek: Record<string, typeof stops> = {};
for (const s of stops) {
if (!byWeek[s.date]) byWeek[s.date] = [];
byWeek[s.date].push(s);
}
const weeks = Object.keys(byWeek).slice(0, 4);
for (const week of weeks) {
const ws = byWeek[week];
console.log(" " + week + " (" + ws.length + " stops):");
for (const s of ws.slice(0, 3)) {
console.log(" " + s.city + ", " + s.state + " | \"" + s.location + "\" | " + s.time);
}
}
// Write output CSVs
fs.mkdirSync(outputDir, { recursive: true });
function writeCSV(filename: string, header: string, rows: string[][]) {
const lines = rows.map((r) =>
r.map((c) => "\"" + String(c ?? "").replace(/\"/g, "\"\"") + "\"").join(",")
);
fs.writeFileSync(path.join(outputDir, filename), header + lines.join("\n"));
}
writeCSV(
"products.csv",
"name,price,type,description,active,image_url,is_taxable\n",
products.map((p) => [
p.name, String(p.price), p.type, p.description,
String(p.active), p.image_url, String(p.is_taxable),
])
);
writeCSV(
"stops.csv",
"city,state,location,date,time,address,notes\n",
stops.map((s) => [s.city, s.state, s.location, s.date, s.time, s.address, s.notes])
);
// Summary
console.log("\n============================================================");
console.log("IMPORT READY");
console.log("============================================================");
console.log("\nproducts.csv: 1 product (\"" + products[0].name + "\" @ $" + products[0].price + " Pickup)");
console.log("stops.csv: " + stops.length + " stops (confirmed + pre-sale only)");
console.log("\nOutput: " + outputDir + "/");
console.log("");
console.log(">>> NOTE: stops.csv 'date' field is a WEEK RANGE (e.g. Jul 22-28).");
console.log("After importing stops, visit /admin/stops to set the actual date for each stop.");
console.log("");
console.log("NEXT STEPS:");
console.log(" 1. Upload " + outputDir + "/products.csv via /admin/import -> type: products");
console.log(" 2. Upload " + outputDir + "/stops.csv via /admin/import -> type: stops");
console.log(" 3. Visit /admin/stops -- set each stop's actual date, verify address/time");
console.log(" 4. Visit /tuxedo -- verify product and stops appear\n");
console.log("Re-run: npx tsx scripts/import-woo-to-route.ts --xlsx \"" + xlsxPath + "\" --brand " + brandId + " --output " + outputDir + "\n");
}
main().catch(console.error);
-96
View File
@@ -1,96 +0,0 @@
/**
* Seed an admin user for development.
* Run: npx tsx scripts/seed-admin.ts [email] [password] [name]
*/
import { config } from "dotenv";
config({ path: ".env.local" });
import pg from "pg";
const { Pool } = pg;
async function main() {
const email = process.argv[2] ?? "admin@test.com";
const password = process.argv[3] ?? "Admin1234!";
const name = process.argv[4] ?? "Admin";
const baseUrl = process.env.NEON_AUTH_BASE_URL!;
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
// 1. Create or get brand
const brandResult = await pool.query(`
INSERT INTO brands (name, slug, plan_tier)
VALUES ('Demo Brand', 'demo', 'enterprise')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id, name, slug
`);
const brand = brandResult.rows[0];
console.log("✓ Brand:", brand.name, `(${brand.id})`);
// 2. Create user in Neon Auth via direct API
const res = await fetch(`${baseUrl}/sign-up/email`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Origin": "http://localhost:4000",
"x-neon-auth-proxy": "node",
},
body: JSON.stringify({ email, password, name, callbackURL: "/admin" }),
});
const data = await res.json();
if (!res.ok && data.code !== "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
console.error("✗ Failed to create user:", data);
process.exit(1);
}
const userId = data.user?.id;
console.log("✓ Neon Auth user:", email, userId ? `(${userId})` : "(already existed)");
// 3. Verify email in Neon Auth DB
if (userId) {
await pool.query(
`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`,
[userId]
);
console.log("✓ Email verified in DB");
}
// 4. Get user ID from neon_auth.user by email
const neonUser = await pool.query(
`SELECT id FROM neon_auth.user WHERE email = $1`,
[email]
);
const neonUserId = neonUser.rows[0]?.id;
if (!neonUserId) {
console.error("✗ Could not find user in neon_auth.user");
process.exit(1);
}
// 5. Insert into admin_users (upsert)
const adminResult = await pool.query(`
INSERT INTO admin_users (user_id, email, name, role)
VALUES ($1, $2, $3, 'brand_admin')
ON CONFLICT (email) DO UPDATE SET user_id = EXCLUDED.user_id, name = EXCLUDED.name, role = EXCLUDED.role
RETURNING id
`, [neonUserId, email, name]);
const adminUser = adminResult.rows[0];
console.log("✓ Admin user:", email, `(${adminUser.id})`);
// 6. Link to brand
await pool.query(`
INSERT INTO admin_user_brands (admin_user_id, brand_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, [adminUser.id, brand.id]);
console.log("✓ Linked to brand:", brand.name);
console.log(`\n🎉 All set! Log in at http://localhost:4000/login`);
console.log(` Email: ${email}`);
console.log(` Password: ${password}`);
} finally {
await pool.end();
}
}
main();
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
/**
* Seed Tuxedo Corn 2026 tour: locations + stops.
*
* Uses sql`...` tagged template from @neondatabase/serverless (WebSocket mode)
* for INSERTs this is the only Neon serverless API that actually executes writes.
*
* Run with:
* node scripts/seed-tuxedo-2026.js
*
* Requires DATABASE_URL in .env.local (or pass explicitly).
*/
require("dotenv").config({ path: ".env.local" });
const { neon } = require("@neondatabase/serverless");
const ExcelJS = require("exceljs");
const path = require("path");
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const YEAR = 2026;
const url = process.env.DATABASE_URL;
if (!url) {
console.error("❌ DATABASE_URL not set in .env.local");
process.exit(1);
}
const cleanUrl = url.replace(/[?&]channel_binding=require/gi, "").trim();
// Use direct compute endpoint with WebSocket for full DDL + DML support
const directUrl = cleanUrl.replace("-pooler.", ".");
const sql = neon(directUrl, { webSocket: true });
const MONTH_MAP = {
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
};
function parseExcelDate(s) {
if (!s) return null;
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
if (!m) return null;
const mm = MONTH_MAP[m[1]];
if (!mm) return null;
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
}
function parseTimeRange(s) {
if (!s) return "";
return String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
}
async function parseXlsx(xlsxPath) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(xlsxPath);
// --- Locations from Stop Directory ---
const locSheet = workbook.getWorksheet("Stop Directory");
const locations = [];
const seenLoc = new Set();
if (locSheet) {
locSheet.eachRow((row, rowNum) => {
if (rowNum === 1) return;
// Column mapping via getCell():
// col1=Truck, col2=City, col3=State, col4=Host(venue name), col5=Address, col6=Phone, col7=Contact, col12=Notes
const rawCity = String(row.getCell(2).value || "").trim();
const rawState = String(row.getCell(3).value || "").trim();
const rawName = String(row.getCell(4).value || "").trim();
const rawAddress = String(row.getCell(5).value || "").trim();
if (!rawCity || !rawName) return;
const key = `${rawName}|${rawCity}|${rawState}`;
if (seenLoc.has(key)) return;
seenLoc.add(key);
locations.push({
name: rawName,
address: rawAddress || null,
city: rawCity,
state: rawState,
zip: null,
phone: String(row.getCell(6).value || "").trim() || null,
contact_name: String(row.getCell(7).value || "").trim() || null,
contact_email: null,
notes: String(row.getCell(12).value || "").trim() || null,
active: true,
});
});
}
console.log(`Parsed ${locations.length} locations`);
// --- Stops from Full Schedule ---
const schedSheet = workbook.getWorksheet("Full Schedule");
const stops = [];
const venueAddressMap = new Map();
if (locSheet) {
locSheet.eachRow((row, rowNum) => {
if (rowNum === 1) return;
const name = String(row.getCell(4).value || "").trim();
const address = String(row.getCell(5).value || "").trim();
if (name && address) venueAddressMap.set(name, address);
});
}
if (schedSheet) {
schedSheet.eachRow((row) => {
const rowValues = row.values || [];
// Column mapping (1-indexed, col1=null):
// col2=Wk, col3=Type, col4=Date, col5=Day, col6=City/Location, col7=Host/Venue, col8=Time, col9=Truck, col10=Status, col11=Notes
const firstCell = String(rowValues[1] || "").trim();
// Skip title rows, week headers ("Wk 1"), OFF, Cross-Dock
if (!firstCell || /^Wk\s*\d+/i.test(firstCell) || firstCell === "OFF" || firstCell === "Cross-Dock") return;
const dateStr = parseExcelDate(rowValues[3]);
if (!dateStr) return;
const cityStateRaw = String(rowValues[5] || "").trim();
const cityParts = cityStateRaw.split(",").map((s) => s.trim());
const city = cityParts[0] || "";
const state = cityParts[1] || "";
const location = String(rowValues[6] || "").trim();
if (!city || !state || !location) return;
const rawTime = rowValues[7];
const time = rawTime ? parseTimeRange(rawTime) : "";
const truck = String(rowValues[8] || "").trim();
const statusText = String(rowValues[9] || "").trim();
const notesText = String(rowValues[10] || "").trim();
const venueAddress = venueAddressMap.get(location) || null;
const notes = [truck ? `Truck: ${truck}` : "", statusText, notesText].filter(Boolean).join(" | ");
stops.push({
name: `${location} - ${city}, ${state}`,
location,
address: venueAddress,
city,
state,
zip: null,
date: dateStr,
time,
cutoff_date: null,
status: "active",
is_public: true,
notes: notes || null,
});
});
}
console.log(`Parsed ${stops.length} stops`);
return { locations, stops };
}
async function seedLocations(locs) {
if (locs.length === 0) return;
await sql`DELETE FROM locations WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid`;
for (const loc of locs) {
await sql`INSERT INTO locations (brand_id, name, address, city, state, zip, phone, contact_name, contact_email, notes, active)
VALUES (${TUXEDO_BRAND_ID}::uuid, ${loc.name}, ${loc.address}, ${loc.city}, ${loc.state}, ${loc.zip}, ${loc.phone}, ${loc.contact_name}, ${loc.contact_email}, ${loc.notes}, ${loc.active})`;
}
console.log(`Seeded ${locs.length} locations`);
}
async function seedStops(stops) {
if (stops.length === 0) return;
const minDate = `${YEAR}-07-01`;
const maxDate = `${YEAR}-09-30`;
await sql`DELETE FROM stops WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid AND date >= ${minDate} AND date <= ${maxDate}`;
for (const stop of stops) {
await sql`INSERT INTO stops (brand_id, name, location, address, city, state, zip, date, "time", cutoff_date, status, is_public, notes)
VALUES (${TUXEDO_BRAND_ID}::uuid, ${stop.name}, ${stop.location}, ${stop.address}, ${stop.city}, ${stop.state}, ${stop.zip}, ${stop.date}, ${stop.time}, ${stop.cutoff_date}, ${stop.status}, ${stop.is_public}, ${stop.notes})`;
}
console.log(`Seeded ${stops.length} stops`);
}
async function main() {
const xlsxPath = path.join(__dirname, "..", "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx");
console.log("Parsing:", xlsxPath);
const { locations, stops } = await parseXlsx(xlsxPath);
if (locations.length === 0 && stops.length === 0) {
console.error("❌ No data parsed from xlsx");
process.exit(1);
}
console.log(`Seeding ${locations.length} locations + ${stops.length} stops for Tuxedo Corn...`);
await seedLocations(locations);
await seedStops(stops);
const locCount = await sql.query(`SELECT COUNT(*) FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
const stopCount = await sql.query(`SELECT COUNT(*) FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
console.log(`\n✅ Done — ${locCount[0].count} locations, ${stopCount[0].count} stops in DB`);
}
main().catch((e) => {
console.error("❌ Seed failed:", e.message);
process.exit(1);
});
-41
View File
@@ -1,41 +0,0 @@
import { config } from "dotenv";
config({ path: ".env.local" });
import pg from "pg";
const { Pool } = pg;
const TUXEDO = "64294306-5f42-463d-a5e8-2ad6c81a96de";
async function main() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
// brand_settings
const bs = await pool.query(
`INSERT INTO brand_settings (brand_id, legal_business_name, phone, email, logo_url, tagline, from_email)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (brand_id) DO UPDATE SET legal_business_name = EXCLUDED.legal_business_name
RETURNING brand_id, legal_business_name`,
[TUXEDO, "Tuxedo Corn LLC", "970-555-0100", "info@tuxedocorn.com",
"/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png",
"Farm-Fresh Sweet Corn, Delivered", "orders@tuxedocorn.com"]
);
console.log("brand_settings:", bs.rows[0]);
// wholesale_settings
const ws = await pool.query(
`INSERT INTO wholesale_settings (brand_id, require_approval, pickup_location, fob_location, from_email)
VALUES ($1, true, $2, $3, $4)
ON CONFLICT (brand_id) DO UPDATE SET pickup_location = EXCLUDED.pickup_location
RETURNING brand_id, pickup_location`,
[TUXEDO, "59751 David Road, Olathe, CO 81425", "FOB Olathe, CO", "orders@tuxedocorn.com"]
);
console.log("wholesale_settings:", ws.rows[0]);
console.log("\nDone!");
} finally {
await pool.end();
}
}
main().catch((e) => { console.error(e.message); process.exit(1); });
-343
View File
@@ -1,343 +0,0 @@
#!/bin/bash
# Seed script for Route Commerce Platform
# Run with: bash scripts/seed.sh
set -e
# Use service role key for write operations during seeding
SUPABASE_URL="${SUPABASE_URL:-https://your-project.supabase.co}"
SERVICE_KEY="${SUPABASE_SERVICE_ROLE_KEY}"
ANON_KEY="${ANON_KEY:-$NEXT_PUBLIC_SUPABASE_ANON_KEY}"
AUTH_HEADER="apikey: $SERVICE_KEY"
CONTENT_TYPE="Content-Type: application/json"
echo "Fetching brands..."
BRANDS=$(curl -s "$SUPABASE_URL/rest/v1/brands?select=id,name" \
-H "$AUTH_HEADER")
echo "Brands: $BRANDS"
TUXEDO_ID=$(echo "$BRANDS" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
IRD_ID=$(echo "$BRANDS" | grep -o '"id":"[^"]*"' | tail -1 | cut -d'"' -f4)
echo "Tuxedo: $TUXEDO_ID"
echo "IRD: $IRD_ID"
echo ""
echo "Creating stops..."
STOP1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$TUXEDO_ID"'",
"city": "Burlington",
"state": "NC",
"date": "2026-05-15",
"time": "8:00 AM 2:00 PM",
"location": "102 W Front St, Burlington, NC",
"slug": "burlington-nc-2026-05-15",
"active": true
}')
echo "Stop 1: $STOP1"
STOP2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$TUXEDO_ID"'",
"city": "Greensboro",
"state": "NC",
"date": "2026-05-16",
"time": "8:00 AM 2:00 PM",
"location": "200 S Elm St, Greensboro, NC",
"slug": "greensboro-nc-2026-05-16",
"active": true
}')
echo "Stop 2: $STOP2"
STOP3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$IRD_ID"'",
"city": "Fort Pierce",
"state": "FL",
"date": "2026-05-15",
"time": "7:00 AM 1:00 PM",
"location": "1224 N US-1, Fort Pierce, FL",
"slug": "fort-pierce-fl-2026-05-15",
"active": true
}')
echo "Stop 3: $STOP3"
echo ""
echo "Creating products..."
PROD1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$TUXEDO_ID"'",
"name": "Sweet Corn",
"description": "Fresh picked white sweet corn, 8 ears per bag",
"price": 12,
"type": "Sweet Corn",
"active": true
}')
echo "Prod 1: $PROD1"
PROD2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$TUXEDO_ID"'",
"name": "Peaches",
"description": "Just-peeled freestone peaches, pint jar",
"price": 8,
"type": "Fruit",
"active": true
}')
echo "Prod 2: $PROD2"
PROD3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$TUXEDO_ID"'",
"name": "Corn & Peach Bundle",
"description": "Sweet corn + peaches combo, save $2",
"price": 18,
"type": "Bundle",
"active": true
}')
echo "Prod 3: $PROD3"
PROD4=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$IRD_ID"'",
"name": "Navel Oranges",
"description": "Premium navel oranges, 4 lb bag",
"price": 15,
"type": "Citrus",
"active": true
}')
echo "Prod 4: $PROD4"
PROD5=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$IRD_ID"'",
"name": "Grapefruit",
"description": "Ruby red grapefruit, 3 lb bag",
"price": 12,
"type": "Citrus",
"active": true
}')
echo "Prod 5: $PROD5"
PROD6=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$IRD_ID"'",
"name": "Citrus Sampler",
"description": "Oranges, grapefruit & tangelos, 6 lb mix",
"price": 22,
"type": "Bundle",
"active": true
}')
echo "Prod 6: $PROD6"
P1_ID=$(echo "$PROD1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
P2_ID=$(echo "$PROD2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
P3_ID=$(echo "$PROD3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
P4_ID=$(echo "$PROD4" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
P5_ID=$(echo "$PROD5" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
P6_ID=$(echo "$PROD6" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
S1_ID=$(echo "$STOP1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
S2_ID=$(echo "$STOP2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
S3_ID=$(echo "$STOP3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
echo ""
echo "Assigning products to stops..."
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P1_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P2_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P3_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S2_ID"'", "product_id": "'"$P1_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S2_ID"'", "product_id": "'"$P3_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P4_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P5_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P6_ID"'"}' > /dev/null
echo ""
echo "Creating orders..."
ORDER1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"customer_name": "Sarah Mitchell",
"customer_email": "sarah.m@email.com",
"customer_phone": "(336) 555-0142",
"stop_id": "'"$S1_ID"'",
"status": "pending",
"subtotal": 32,
"discount_amount": 0,
"pickup_complete": false,
"payment_processor": "manual",
"payment_status": "paid"
}')
echo "Order 1: $ORDER1"
ORDER2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"customer_name": "James Rivera",
"customer_email": "jrivera@email.com",
"customer_phone": "(336) 555-0298",
"stop_id": "'"$S1_ID"'",
"status": "confirmed",
"subtotal": 22,
"discount_amount": 2,
"discount_reason": "First-time customer",
"pickup_complete": true,
"payment_processor": "manual",
"payment_status": "paid"
}')
echo "Order 2: $ORDER2"
ORDER3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"customer_name": "Lisa Chen",
"customer_phone": "(919) 555-0763",
"stop_id": "'"$S2_ID"'",
"status": "pending",
"subtotal": 12,
"pickup_complete": false,
"payment_processor": "manual",
"payment_status": "pending"
}')
echo "Order 3: $ORDER3"
ORDER4=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"customer_name": "Marcus Thompson",
"customer_email": "mthompson@email.com",
"customer_phone": "(772) 555-0119",
"stop_id": "'"$S3_ID"'",
"status": "confirmed",
"subtotal": 37,
"pickup_complete": false,
"payment_processor": "venmo",
"payment_status": "paid",
"payment_transaction_id": "VEN-88420"
}')
echo "Order 4: $ORDER4"
ORDER5=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"customer_name": "Amanda Foster",
"customer_email": "afoster@email.com",
"stop_id": "'"$S3_ID"'",
"status": "cancelled",
"subtotal": 15,
"pickup_complete": false,
"internal_notes": "Customer cancelled after hours, no show next day",
"payment_processor": "manual",
"payment_status": "refunded",
"refunded_amount": 15,
"refund_reason": "Customer cancelled"
}')
echo "Order 5: $ORDER5"
O1_ID=$(echo "$ORDER1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
O2_ID=$(echo "$ORDER2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
O3_ID=$(echo "$ORDER3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
O4_ID=$(echo "$ORDER4" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
O5_ID=$(echo "$ORDER5" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
echo ""
echo "Creating order items..."
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O1_ID"'", "product_id": "'"$P1_ID"'", "quantity": 2, "price": 12}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O1_ID"'", "product_id": "'"$P2_ID"'", "quantity": 1, "price": 8}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O2_ID"'", "product_id": "'"$P3_ID"'", "quantity": 1, "price": 18}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O2_ID"'", "product_id": "'"$P1_ID"'", "quantity": 2, "price": 12}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O3_ID"'", "product_id": "'"$P1_ID"'", "quantity": 1, "price": 12}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O4_ID"'", "product_id": "'"$P4_ID"'", "quantity": 2, "price": 15}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O4_ID"'", "product_id": "'"$P5_ID"'", "quantity": 1, "price": 12}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O5_ID"'", "product_id": "'"$P4_ID"'", "quantity": 1, "price": 15}' > /dev/null
echo ""
echo "Done! Seed data created:"
echo " 3 stops (Burlington NC, Greensboro NC, Fort Pierce FL)"
echo " 6 products (3 Tuxedo Corn, 3 Indian River Direct)"
echo " 5 orders with items"
echo ""
echo "Quick test URLs:"
echo " /admin/orders"
echo " /admin/products"
echo " /admin/stops"
-346
View File
@@ -1,346 +0,0 @@
#!/usr/bin/env python3
"""
seed_tuxedo_tour.py
Parses Tuxedo_Corn_2026_Tour_Schedule-3.xlsx and seeds the `stops` table for
the Tuxedo brand via the admin_create_stops_batch RPC.
Skips:
- Title / subtitle / legend rows (rows 1-3)
- Week header rows (col A = "Wk N", col D-J empty)
- Cross-Dock / Monday OFF rows (col D contains "OFF" or "Cross-Dock")
Joins with the Stop Directory sheet to enrich each stop with:
- address, phone, contact
Usage:
python3 scripts/seed_tuxedo_tour.py --dry-run # show what would be inserted
python3 scripts/seed_tuxedo_tour.py # actually insert
Requires:
- supabase CLI linked to project wnzkhezyhnfzhkhiflrp
"""
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from openpyxl import load_workbook
TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"
YEAR = 2026
MONTH_MAP = {
"Jan": "01", "Feb": "02", "Mar": "03", "Apr": "04",
"May": "05", "Jun": "06", "Jul": "07", "Aug": "08",
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
}
DEFAULT_XLSX = (
"/home/coder/dev/x1/kyle/route_commerce-main/"
"Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
)
def parse_excel_date(s):
"""'Jul 22' -> '2026-07-22'"""
if not s:
return None
m = re.match(r"^([A-Za-z]{3})\s+(\d{1,2})$", str(s).strip())
if not m:
return None
mm = MONTH_MAP.get(m.group(1))
if not mm:
return None
return f"{YEAR}-{mm}-{int(m.group(2)):02d}"
def parse_time_range(s):
"""'10:00 AM - 1:00 PM' -> '10:00 AM' (start time)"""
if not s:
return ""
cleaned = re.sub(r"[–—]", "-", str(s)).strip()
cleaned = re.sub(r"\s+", " ", cleaned)
m = re.match(r"^(\d{1,2}:\d{2}\s*[AP]M)", cleaned, re.IGNORECASE)
return m.group(1).upper().replace(" ", " ") if m else cleaned
def split_city_state(s):
"""'Cheyenne, WY' -> ('Cheyenne', 'WY')"""
if not s:
return "", ""
parts = [p.strip() for p in str(s).split(",")]
if len(parts) == 1:
return parts[0], ""
return parts[0], parts[1]
def slugify(s):
s = (s or "").lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
def is_week_header(row):
a = str(row[0] or "").strip()
d = str(row[3] or "").strip()
return re.match(r"^Wk\s", a) and d == ""
def is_off_row(row):
d = str(row[3] or "").strip()
return "OFF" in d or "Cross-Dock" in d or "CrossDock" in d
def is_data_row(row):
d = str(row[3] or "").strip()
e = str(row[4] or "").strip()
if not d or not e:
return False
if "," not in e:
return False
return True
def load(xlsx_path):
wb = load_workbook(xlsx_path, data_only=True)
schedule = wb["Full Schedule"]
directory = wb["Stop Directory"]
# Build Stop Directory lookup: (truck, host_normalized) -> {address, phone, contact, ...}
dir_map = {}
for row in directory.iter_rows(min_row=2, values_only=True):
truck = str(row[0] or "").strip()
city = str(row[1] or "").strip()
state = str(row[2] or "").strip()
host = str(row[3] or "").strip()
address = str(row[4] or "").strip()
phone = str(row[5] or "").strip()
contact = str(row[6] or "").strip()
if not truck or not host:
continue
key = f"{truck}|{host.lower()}"
dir_map[key] = {
"city": city, "state": state, "host": host,
"address": address, "phone": phone, "contact": contact,
}
# Read Full Schedule (skip first 3 title/subtitle/legend rows)
stops = []
skipped = {"weekHeader": 0, "off": 0, "invalid": 0}
for row in schedule.iter_rows(min_row=4, values_only=True):
# Trim to 10 cols
cells = [("" if v is None else str(v).strip()) for v in row[:10]]
if is_week_header(cells):
skipped["weekHeader"] += 1
continue
if is_off_row(cells):
skipped["off"] += 1
continue
if not is_data_row(cells):
skipped["invalid"] += 1
continue
wk, region, date_text, day, city_state, host, time, truck, status, notes = cells
date_iso = parse_excel_date(date_text)
if not date_iso:
skipped["invalid"] += 1
continue
city, state = split_city_state(city_state)
if not city:
skipped["invalid"] += 1
continue
# Enrich from directory
dir_key = f"{truck}|{host.lower()}"
d = dir_map.get(dir_key)
stops.append({
"week": wk,
"region": region,
"date": date_iso,
"day": day,
"city": city,
"state": state or (d["state"] if d else ""),
"location": host,
"time": parse_time_range(time),
"time_range": time,
"truck": truck,
"status_text": status,
"notes": notes,
"address": d["address"] if d and d["address"] else None,
"phone": d["phone"] if d and d["phone"] else None,
"contact": d["contact"] if d and d["contact"] else None,
})
return stops, skipped, len(dir_map)
def assign_slugs(stops, dry_run):
used = set()
if not dry_run:
out = subprocess.run(
["supabase", "db", "query", "--linked",
f"SELECT slug FROM stops WHERE brand_id = '{TUXEDO_BRAND_ID}';"],
capture_output=True, text=True, timeout=120,
)
# Parse the table output - slugs are in second column between │
for m in re.finditer(r"\s*([a-z0-9][a-z0-9-]*)\s*│", out.stdout):
used.add(m.group(1))
for s in stops:
base = f"{slugify(s['city'])}-{s['date']}"
slug = base
n = 0
while slug in used:
n += 1
slug = f"{base}-{n}"
used.add(slug)
s["slug"] = slug
def to_rpc_row(s):
return {
"city": s["city"],
"state": s["state"],
"location": s["location"],
"date": f"{s['date']} 00:00:00+00",
"time": s["time"],
"address": s["address"],
"zip": None,
"cutoff_time": None,
# active=true so the stops appear on the public storefront immediately.
# Matches the behavior of publishStop in src/actions/stops.ts.
"active": True,
}
def build_payload_json(batch):
"""Build a clean JSON string for use in a SQL file."""
return json.dumps(batch, ensure_ascii=False)
def insert_batch(batch):
"""Write SQL to a temp file and execute via --file to avoid shell escaping."""
payload_json = build_payload_json(batch)
sql = (
f"SELECT admin_create_stops_batch("
f"'{TUXEDO_BRAND_ID}'::uuid, "
f"$${payload_json}$$::jsonb);\n"
)
# Write to temp file
tmp_path = Path("/tmp/seed_tuxedo_tour.sql")
tmp_path.write_text(sql, encoding="utf-8")
try:
proc = subprocess.run(
["supabase", "db", "query", "--linked", "--file", str(tmp_path)],
capture_output=True, text=True, timeout=300,
)
finally:
tmp_path.unlink(missing_ok=True)
if proc.returncode != 0:
raise RuntimeError(f"RPC failed: {proc.stderr[:800]}")
return proc.stdout
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--xlsx", default=DEFAULT_XLSX)
args = ap.parse_args()
if not Path(args.xlsx).exists():
sys.exit(f"XLSX not found: {args.xlsx}")
stops, skipped, dir_count = load(args.xlsx)
assign_slugs(stops, dry_run=args.dry_run)
print(f"\nParsed {len(stops)} stops "
f"(skipped: {skipped['weekHeader']} week-headers, "
f"{skipped['off']} OFF days, {skipped['invalid']} invalid)")
print(f"Stop Directory: {dir_count} entries loaded for enrichment\n")
if not stops:
sys.exit("No stops to insert.")
print("Sample (first 3):")
for s in stops[:3]:
print(f" {s['date']} {s['time']:10s} | {s['city']:18s}, {s['state']:2s} | "
f"{s['location'][:35]:35s} | {s['truck']} | {s['status_text']} | {s['slug']}")
if s["notes"]:
print(f" notes: {s['notes'][:120]}")
if s["address"]:
print(f" addr: {s['address']} ph: {s['phone']} ctc: {s['contact']}")
print()
# Show counts by week and region
by_week = {}
by_region = {}
by_truck = {}
for s in stops:
by_week[s["week"]] = by_week.get(s["week"], 0) + 1
by_region[s["region"]] = by_region.get(s["region"], 0) + 1
by_truck[s["truck"]] = by_truck.get(s["truck"], 0) + 1
print("By week:", dict(sorted(by_week.items())))
print("By region:", by_region)
print("By truck:", by_truck)
print()
# Date range
dates = sorted(s["date"] for s in stops)
print(f"Date range: {dates[0]} to {dates[-1]}\n")
if args.dry_run:
batches = (len(stops) + 49) // 50
print(f"[DRY RUN] Would insert {len(stops)} stops in {batches} batch(es) of 50.")
return
BATCH = 50
total = 0
batches = (len(stops) + BATCH - 1) // BATCH
for i in range(0, len(stops), BATCH):
batch = [to_rpc_row(s) for s in stops[i:i + BATCH]]
bnum = i // BATCH + 1
sys.stdout.write(f" Inserting batch {bnum}/{batches} ({len(batch)} stops)... ")
sys.stdout.flush()
try:
insert_batch(batch)
total += len(batch)
print("OK")
except Exception as e:
print("FAIL")
print(f" {e}")
# The batch RPC hardcodes status='draft' on insert. The Tuxedo storefront
# page only filters on active=true (not status), so active=true is enough
# to make stops visible. But for consistency with the publishStop server
# action — which sets both — flip status to 'active' for the rows we just
# inserted. Slug-based so we only touch stops from this run, not the
# pre-existing "Olathe" test stop.
if total > 0:
slugs = [s["slug"] for s in stops]
# Build a safe IN list (slug is a text column)
slug_list = ", ".join(f"'{slug.replace(chr(39), chr(39)+chr(39))}'" for slug in slugs)
publish_sql = (
f"UPDATE stops SET status = 'active' "
f"WHERE brand_id = '{TUXEDO_BRAND_ID}' "
f"AND slug IN ({slug_list});"
)
tmp = Path("/tmp/seed_tuxedo_publish.sql")
tmp.write_text(publish_sql, encoding="utf-8")
try:
subprocess.run(
["supabase", "db", "query", "--linked", "--file", str(tmp)],
capture_output=True, text=True, timeout=120,
)
print(f"\n Published {total} stops (status -> 'active').")
finally:
tmp.unlink(missing_ok=True)
print(f"\nDone. Inserted {total}/{len(stops)} stops for Tuxedo brand.")
if __name__ == "__main__":
main()
-74
View File
@@ -1,74 +0,0 @@
import { createClient } from "@supabase/supabase-js";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import path from "path";
// Load .env.local manually
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const envPath = path.join(__dirname, "..", ".env.local");
const envContent = readFileSync(envPath, "utf8");
envContent.split("\n").forEach((line) => {
const [key, ...rest] = line.split("=");
if (key && rest.length && key.trim() && !key.trim().startsWith("#")) {
process.env[key.trim()] = rest.join("=").trim().replace(/^"|"$/g, "");
}
});
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
console.error("Missing env vars");
process.exit(1);
}
const supabase = createClient(supabaseUrl, supabaseServiceKey);
async function uploadTuxedoHeroVideo() {
const filePath = "/Users/kylemartinez/Downloads/062624TuxedoCorn 001.mp4";
const fileBuffer = readFileSync(filePath);
const fileSizeMB = (fileBuffer.byteLength / 1024 / 1024).toFixed(2);
console.log(`File: ${filePath} (${fileSizeMB} MB)`);
// Ensure public videos bucket exists
const { data: buckets, error: listErr } = await supabase.storage.listBuckets();
if (listErr) { console.error("List buckets error:", listErr.message); process.exit(1); }
const existing = buckets?.find((b) => b.name === "videos");
if (!existing) {
console.log("Creating 'videos' bucket (public)...");
const { error: createErr } = await supabase.storage.createBucket("videos", { public: true });
if (createErr) { console.error("Create bucket error:", createErr.message); process.exit(1); }
console.log("Bucket 'videos' created.");
} else {
console.log(`'videos' bucket exists (public: ${existing.public}).`);
if (!existing.public) {
const { error: updErr } = await supabase.storage.updateBucket("videos", { public: true });
if (updErr) console.warn("Could not update bucket to public:", updErr.message);
else console.log("Bucket updated to public.");
}
}
// Upload
console.log("Uploading tuxedo-hero.mp4...");
const { data: uploadData, error: uploadErr } = await supabase.storage
.from("videos")
.upload("tuxedo-hero.mp4", fileBuffer, {
contentType: "video/mp4",
upsert: true,
});
if (uploadErr) {
console.error("Upload error:", uploadErr.message);
process.exit(1);
}
console.log("Upload OK:", JSON.stringify(uploadData));
// Public URL
const { data: urlData } = supabase.storage.from("videos").getPublicUrl("tuxedo-hero.mp4");
const url = urlData.publicUrl;
console.log(`\n✅ Public URL:\n${url}`);
}
uploadTuxedoHeroVideo().catch((e) => { console.error(e); process.exit(1); });
-11
View File
@@ -1,11 +0,0 @@
import { config } from "dotenv";
config({ path: ".env.local" });
import pg from "pg";
const { Pool } = pg;
async function main() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const r = await pool.query(`SELECT id, email, "emailVerified" FROM neon_auth.user WHERE email = 'admin@test.com'`);
console.log(JSON.stringify(r.rows, null, 2));
await pool.end();
}
main();
-132
View File
@@ -1,132 +0,0 @@
#!/usr/bin/env node
/**
* verify-stop-fns.js
*
* Confirms `admin_create_stop` and `admin_create_stops_batch` exist in the
* live database with the exact signatures the client uses, and that the
* PostgREST schema cache has them registered.
*
* Run: node scripts/verify-stop-fns.js
*
* Replaces the older `check-stop-fns*.js` scripts (left in the repo for
* history; this one is the canonical post-fix verification).
*
* Exits 0 on success, 1 if any check fails.
*/
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !serviceKey || !anonKey) {
console.error("Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / NEXT_PUBLIC_SUPABASE_ANON_KEY in .env.local");
process.exit(1);
}
const SERVICE_HEADERS = {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
"Content-Type": "application/json",
};
const ANON_HEADERS = {
apikey: anonKey,
Authorization: `Bearer ${anonKey}`,
"Content-Type": "application/json",
};
let failed = 0;
const fail = (msg) => { console.error("✗ " + msg); failed += 1; };
const pass = (msg) => console.log("✓ " + msg);
async function rpc(name, body, headers = SERVICE_HEADERS) {
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
const text = await r.text();
let parsed;
try { parsed = JSON.parse(text); } catch { parsed = text.slice(0, 200); }
return { status: r.status, body: parsed };
}
(async () => {
console.log(`\nVerifying stop RPCs against ${url}\n`);
// ── 1. admin_create_stop — empty body (expect 400/422, NOT PGRST202) ─────
// PGRST202 = function not in schema cache. We want a 400-style validation
// error here, which proves PostgREST knows the function exists.
console.log("1. admin_create_stop signature check (PostgREST cache)");
const probe1 = await rpc("admin_create_stop", {});
if (probe1.status === 404) {
fail("admin_create_stop returned 404 — function not found (PGRST202 cache miss). " +
"Did you apply migration 147 and run `NOTIFY pgrst, 'reload schema'`?");
} else if (probe1.status >= 500) {
fail(`admin_create_stop returned ${probe1.status} — server error. Body: ${JSON.stringify(probe1.body)}`);
} else {
pass(`admin_create_stop reachable in PostgREST (status ${probe1.status})`);
}
// ── 2. admin_create_stop — happy path with a real (but harmless) brand ──
console.log("\n2. admin_create_stop happy-path call (anon key)");
const probe2 = await rpc(
"admin_create_stop",
{
p_brand_id: "00000000-0000-0000-0000-000000000000",
p_city: "verify-script-city",
p_state: "CO",
p_location: "verification stop",
p_date: "2099-12-31",
p_time: "08:00 AM 02:00 PM",
p_address: "123 Verify St",
p_zip: "80000",
p_cutoff_time: "08:00", // time-only — should be combined with date
p_active: false,
},
ANON_HEADERS
);
if (probe2.status === 404) {
fail("admin_create_stop not callable via anon key. " +
"GRANT EXECUTE missing? Body: " + JSON.stringify(probe2.body));
} else if (probe2.status >= 400) {
// Expected: brand_id is the zero UUID (foreign key violation). That's fine —
// it proves the function executed past type validation.
const msg = JSON.stringify(probe2.body);
if (/foreign key|violates/i.test(msg)) {
pass(`admin_create_stop ran end-to-end (got expected FK violation: ${msg.slice(0, 120)}...)`);
} else {
fail(`admin_create_stop failed unexpectedly (${probe2.status}): ${msg}`);
}
} else {
pass(`admin_create_stop returned success: ${JSON.stringify(probe2.body)}`);
}
// ── 3. admin_create_stops_batch — same checks ───────────────────────────
console.log("\n3. admin_create_stops_batch signature check");
const probe3 = await rpc("admin_create_stops_batch", { p_brand_id: null, p_stops: [] });
if (probe3.status === 404) {
fail("admin_create_stops_batch returned 404 — function not in schema cache.");
} else {
pass(`admin_create_stops_batch reachable (status ${probe3.status})`);
}
// ── 4. OpenAPI introspection — confirm functions are exposed ────────────
console.log("\n4. OpenAPI introspection");
const openApi = await fetch(`${url}/rest/v1/`, { headers: ANON_HEADERS });
const text = await openApi.text();
const hasSingle = /admin_create_stop\b/.test(text);
const hasBatch = /admin_create_stops_batch\b/.test(text);
if (hasSingle) pass("admin_create_stop appears in OpenAPI spec");
else fail("admin_create_stop NOT in OpenAPI spec — PostgREST hasn't seen it yet");
if (hasBatch) pass("admin_create_stops_batch appears in OpenAPI spec");
else fail("admin_create_stops_batch NOT in OpenAPI spec");
console.log(`\n${failed === 0 ? "✓ All checks passed" : `${failed} check(s) failed`}\n`);
process.exit(failed === 0 ? 0 : 1);
})().catch((e) => {
console.error("verify-stop-fns.js crashed:", e);
process.exit(1);
});
+123 -16
View File
@@ -1,42 +1,149 @@
"use server";
import "server-only";
import { randomBytes } from "crypto";
import { query } from "@/lib/db";
import {
requestPasswordReset as neonAuthRequestPasswordReset,
setUserPassword as neonAuthSetUserPassword,
} from "@/lib/auth";
import { getAdminUser } from "@/lib/admin-permissions";
export type ResetAdminPasswordResult =
| { success: true; tempPassword: string }
| { success: true; tempPassword: string; method: "set" }
| { success: true; method: "reset_link_sent"; message: string }
| { success: false; error: string };
/**
* Emergency recovery action only usable in development or when the caller
* already has direct DB access. Resets the password for the specified email
* and returns the temp password so it can be displayed to the user.
* Emergency recovery action resets the password for the specified
* email so the platform admin can hand a working credential to the
* user.
*
* Now that Supabase auth is gone, this hits the `users` table directly and
* calls the same `update_user_password` SECURITY DEFINER RPC used by the
* self-service password change action.
* Tries, in order:
* 1. `auth.admin.setUserPassword({ userId, newPassword })` instant,
* works when the caller has `role='admin'` in `neon_auth.user`.
* 2. Falls back to `requestPasswordReset({ email, redirectTo })`
* public Neon Auth endpoint, always works, but the user has to
* click the link in the email. The fallback covers the common
* case where `provision-admin.ts` did not promote the caller to
* `role='admin'` in Neon Auth.
*
* Authorization: platform_admin only. The dev-session platform_admin
* shim is honored (same as the rest of the admin actions).
*/
export async function resetAdminPassword(
email: string,
newPassword: string
): Promise<ResetAdminPasswordResult> {
// Look up the user by email
// 1. Authz check.
const me = await getAdminUser();
if (!me) {
return { success: false, error: "Not authenticated." };
}
if (me.role !== "platform_admin") {
return {
success: false,
error: "Only platform admins can reset passwords.",
};
}
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail) {
return { success: false, error: "Email is required." };
}
// 2. Look up the Neon Auth user id by email.
const { rows } = await query<{ id: string }>(
"SELECT id FROM users WHERE email = $1 LIMIT 1",
[email.toLowerCase()],
`SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1`,
[normalizedEmail],
);
const user = rows[0];
if (!user) {
return { success: false, error: "No auth user found for that email address." };
return {
success: false,
error: `No Neon Auth user found for ${normalizedEmail}.`,
};
}
// Update password via SECURITY DEFINER RPC
// 3. Generate a strong temp password server-side. 16 bytes → 32
// base64url chars; mix in a symbol so it satisfies typical
// password policies.
const tempPassword = `${randomBytes(18).toString("base64url")}!`;
// 4. Try the privileged admin endpoint.
try {
await query("SELECT update_user_password($1, $2)", [user.id, newPassword]);
return { success: true, tempPassword: newPassword };
const adminResult = await neonAuthSetUserPassword({
userId: user.id,
newPassword: tempPassword,
});
if (adminResult?.error) {
const code = adminResult.error.code ?? "";
const canFallback =
code === "FORBIDDEN" ||
code === "UNAUTHORIZED" ||
code === "INTERNAL_SERVER_ERROR";
console.warn(
"[resetAdminPassword] setUserPassword failed (code=%s), will%s fall back: %s",
code,
canFallback ? "" : " NOT",
adminResult.error.message ?? "",
);
if (!canFallback) {
return {
success: false,
error: adminResult.error.message ?? `setUserPassword failed: ${code}`,
};
}
// fall through to the public reset-link path below
} else {
// Success — flip must_change_password so the user is forced
// to pick a new password on next sign-in.
try {
await query(
`UPDATE admin_users SET must_change_password = true WHERE user_id = $1`,
[user.id],
);
} catch (flagErr) {
console.warn(
"[resetAdminPassword] failed to set must_change_password (non-fatal):",
flagErr,
);
}
return { success: true, tempPassword, method: "set" };
}
} catch (err) {
console.error("[resetAdminPassword] setUserPassword threw:", err);
// network / unexpected — try the fallback below
}
// 5. Fallback: send a password-reset email via the public endpoint.
try {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
const resetResult = await neonAuthRequestPasswordReset({
email: normalizedEmail,
redirectTo: `${siteUrl}/reset-password`,
});
if (resetResult?.error) {
return {
success: false,
error:
resetResult.error.message ??
resetResult.error.code ??
"Failed to send reset email",
};
}
return {
success: true,
method: "reset_link_sent",
message: `Set-password is not available for this account. Sent a password-reset email to ${normalizedEmail} instead — share that link with the user.`,
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to update password",
error: err instanceof Error ? err.message : String(err),
};
}
}
+347 -46
View File
@@ -1,8 +1,12 @@
"use server";
import "server-only";
import { cookies } from "next/headers";
import { pool, query } from "@/lib/db";
import { query, withTx } from "@/lib/db";
import {
createUser as neonAuthCreateUser,
requestPasswordReset as neonAuthRequestPasswordReset,
} from "@/lib/auth";
import { getAdminUser } from "@/lib/admin-permissions";
export type AdminUserRow = {
id: string;
@@ -110,7 +114,7 @@ async function sendWelcomeEmailSafe(input: {
role: "platform_admin" | "brand_admin" | "store_employee";
password: string;
brandId?: string;
}): Promise<void> {
}): Promise<{ sent: boolean; error?: string }> {
try {
const { sendWelcomeEmail } = await import("@/lib/email-service");
const { getBrandSettings } = await import("@/actions/brand-settings");
@@ -119,14 +123,21 @@ async function sendWelcomeEmailSafe(input: {
let logoUrl: string | null = null;
let brandName = "Route Commerce";
if (input.brandId) {
try {
const settings = await getBrandSettings(input.brandId);
if (settings.success && settings.settings) {
logoUrl = settings.settings.logo_url ?? null;
brandName = settings.settings.brand_name ?? brandName;
}
} catch (brandErr) {
console.warn(
"[createAdminUser] Failed to load brand settings for welcome email:",
brandErr,
);
}
}
await sendWelcomeEmail({
const result = await sendWelcomeEmail({
to: input.to,
name: input.name,
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
@@ -134,8 +145,11 @@ async function sendWelcomeEmailSafe(input: {
tempPassword: input.password,
logoUrl: logoUrl ?? undefined,
});
} catch {
// welcome email is best-effort; never block user creation
return result.ok ? { sent: true } : { sent: false, error: result.error };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[createAdminUser] Welcome email failed (non-fatal):", msg);
return { sent: false, error: msg };
}
}
@@ -170,29 +184,181 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
}
}
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
try {
// No Supabase Auth — `user_id` stays NULL until the user signs in
// via Auth.js and `get_admin_user_for_session` matches them by
// `auth_subject` / `email`. We just insert the row.
export type CreateAdminUserResult = {
user: AdminUserRow | null;
error: string | null;
/**
* The temporary password that was set on the Neon Auth account. Only
* populated on success. The UI should display this once to the caller
* and offer a copy-to-clipboard affordance the password is never
* stored in plaintext and cannot be retrieved again.
*/
tempPassword?: string;
/**
* Whether the welcome email was dispatched. Best-effort the user is
* created even if email fails. False here means the caller should
* share the password out-of-band.
*/
emailSent?: boolean;
/**
* Set when `emailSent` is false. Helps the UI show a useful warning.
*/
emailError?: string;
/**
* Which Neon Auth path was used to create the account. "admin" means
* the caller's Neon Auth session has `role = 'admin'` and we used the
* privileged `auth.admin.createUser` endpoint. "signup" means we had
* to fall back to the public `/sign-up/email` endpoint (e.g. the
* caller's Neon Auth user is not flagged as admin, or the admin
* endpoint rejected the request). The choice is recorded for
* auditability.
*/
authPath?: "admin" | "signup";
};
/**
* Create a new admin user. Steps:
*
* 1. Verify the caller is a `platform_admin`.
* 2. Create the Neon Auth account (with the provided password) and
* link it to the local `admin_users` row via `user_id`. Tries the
* privileged `auth.admin.createUser` first; falls back to the
* public `/sign-up/email` + `emailVerified = true` pattern when
* the caller's Neon Auth session is not an admin (which is the
* common case in dev / when a brand admin's user was not
* provisioned as Neon Auth admin).
* 3. Insert `admin_user_brands` link if a brand was selected.
* 4. Send a welcome email with the temporary password (best-effort).
*
* The temp password is returned to the caller so the UI can show it
* once this is the only opportunity to share it with the new user,
* since Neon Auth stores a hash, not the plaintext.
*/
export async function createAdminUser(
input: CreateAdminUserInput,
): Promise<CreateAdminUserResult> {
// 1. Authorization: only platform admins can mint new admin users.
const caller = await getAdminUser();
if (!caller) {
return { user: null, error: "Not authenticated. Please sign in again." };
}
if (caller.role !== "platform_admin") {
return {
user: null,
error: "Only platform admins can create new admin users.",
};
}
const email = input.email.trim().toLowerCase();
const password = input.password;
const displayName = input.display_name ?? email.split("@")[0];
const f = input.flags;
const { rows } = await query<Record<string, unknown>>(
let neonAuthUserId: string;
let authPath: "admin" | "signup" = "admin";
try {
// 2. Create the Neon Auth account. Try the privileged admin
// endpoint first.
const adminResult = await neonAuthCreateUser({
email,
password,
name: displayName,
});
if (adminResult.data?.user?.id) {
neonAuthUserId = String(adminResult.data.user.id);
authPath = "admin";
} else if (adminResult.error) {
// 2b. Fallback: public sign-up + flip emailVerified to true. The
// admin endpoint requires the caller to have role='admin' in
// Neon Auth, which is not always true (provision-admin.ts does
// not set it). Falling back to sign-up is a safe, well-tested
// path.
const code = adminResult.error.code ?? "";
const isAuthRequired =
code === "FAILED_TO_CREATE_USER" ||
code === "FORBIDDEN" ||
code === "UNAUTHORIZED" ||
code === "INTERNAL_SERVER_ERROR" ||
code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL";
if (code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
return {
user: null,
error: `A Neon Auth user with email ${email} already exists. Pick a different email or reset the existing user's password.`,
};
}
if (!isAuthRequired) {
return {
user: null,
error: `Neon Auth createUser failed: ${adminResult.error.message ?? code ?? "unknown error"}`,
};
}
authPath = "signup";
const signupResult = await signupFallbackCreate(email, password, displayName);
if (!signupResult.userId) {
return {
user: null,
error: `Could not create Neon Auth user via fallback sign-up: ${signupResult.error}`,
};
}
neonAuthUserId = signupResult.userId;
} else {
return {
user: null,
error: "Neon Auth createUser returned neither data nor error.",
};
}
} catch (err) {
return {
user: null,
error: `Neon Auth error: ${err instanceof Error ? err.message : String(err)}`,
};
}
// 3. Insert the local admin_users row + brand link in a transaction.
// We do this AFTER creating the Neon Auth user so a Neon Auth
// failure doesn't leave a dangling local row.
let insertedRow: Record<string, unknown> | null = null;
try {
const result = await withTx(async (client) => {
const ins = await client.query<Record<string, unknown>>(
`INSERT INTO admin_users
(user_id, display_name, email, phone_number, role, brand_id,
can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports,
active, must_change_password, auth_provider, auth_subject)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'pending',$17)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'neon_auth',$17)
ON CONFLICT (user_id) DO UPDATE SET
display_name = EXCLUDED.display_name,
email = EXCLUDED.email,
phone_number = EXCLUDED.phone_number,
role = EXCLUDED.role,
brand_id = EXCLUDED.brand_id,
can_manage_products = EXCLUDED.can_manage_products,
can_manage_stops = EXCLUDED.can_manage_stops,
can_manage_orders = EXCLUDED.can_manage_orders,
can_manage_pickup = EXCLUDED.can_manage_pickup,
can_manage_messages = EXCLUDED.can_manage_messages,
can_manage_refunds = EXCLUDED.can_manage_refunds,
can_manage_users = EXCLUDED.can_manage_users,
can_manage_water_log= EXCLUDED.can_manage_water_log,
can_manage_reports = EXCLUDED.can_manage_reports,
active = EXCLUDED.active,
must_change_password= EXCLUDED.must_change_password,
auth_provider = EXCLUDED.auth_provider
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports,
active, must_change_password, created_at, last_login`,
[
null,
input.display_name ?? input.email.split("@")[0],
input.email.toLowerCase(),
neonAuthUserId,
displayName,
email,
input.phone_number ?? null,
input.role,
input.brand_id,
@@ -206,41 +372,138 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
f.can_manage_water_log ?? false,
f.can_manage_reports ?? false,
input.mustChangePassword ?? true,
input.email.toLowerCase(),
neonAuthUserId,
],
);
if (!rows[0]) return { user: null, error: "Insert returned no row" };
if (!ins.rows[0]) throw new Error("admin_users insert returned no row");
const newAdminId = String(rows[0].id);
// Ensure the admin_user_brands link exists for brand-scoped roles.
// (Platform admins created without a chosen brand may have 0 links and still
// get access via role; getAdminUser allows this.)
if (input.brand_id) {
try {
await query(
await client.query(
`INSERT INTO admin_user_brands (admin_user_id, brand_id)
VALUES ($1, $2)
ON CONFLICT (admin_user_id, brand_id) DO NOTHING`,
[newAdminId, input.brand_id],
[ins.rows[0].id, input.brand_id],
);
} catch (linkErr) {
console.error("[createAdminUser] Failed to create admin_user_brands link:", linkErr);
// Non-fatal — the user row exists; a platform admin can link manually.
}
return ins.rows[0];
});
insertedRow = result;
} catch (err) {
// We created the Neon Auth account but failed to insert the local
// row. Don't roll back the auth user — the operator can re-link
// it via the admin UI / DB. Surface the error.
console.error(
"[createAdminUser] Neon Auth user created (id=" + neonAuthUserId + ") but admin_users insert failed:",
err,
);
return {
user: null,
error:
`Neon Auth user was created but the local admin row failed: ${
err instanceof Error ? err.message : String(err)
}. The Neon Auth account is orphaned link it manually via the admin_users table.`,
};
}
await sendWelcomeEmailSafe({
to: input.email,
name: input.display_name ?? input.email.split("@")[0],
// 4. Welcome email (best-effort).
const emailResult = await sendWelcomeEmailSafe({
to: email,
name: displayName,
role: input.role,
password: input.password,
password,
brandId: input.brand_id ?? undefined,
});
return { user: mapUserRow(rows[0]), error: null };
return {
user: mapUserRow(insertedRow),
error: null,
tempPassword: password,
emailSent: emailResult.sent,
emailError: emailResult.error,
authPath,
};
}
/**
* Public sign-up + emailVerified = true fallback. Used when the
* caller's Neon Auth session is not flagged as admin, or the admin
* endpoint rejected the request for any reason.
*/
async function signupFallbackCreate(
email: string,
password: string,
name: string,
): Promise<{ userId: string | null; error: string | null }> {
const baseUrl = process.env.NEON_AUTH_BASE_URL;
if (!baseUrl) {
return {
userId: null,
error: "NEON_AUTH_BASE_URL is not set; cannot fall back to public sign-up.",
};
}
try {
const res = await fetch(`${baseUrl}/sign-up/email`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Origin": process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000",
"x-neon-auth-proxy": "node",
},
body: JSON.stringify({
email,
password,
name,
callbackURL: "/admin",
}),
});
const data = (await res.json().catch(() => ({}))) as {
user?: { id?: string };
code?: string;
message?: string;
};
if (!res.ok) {
if (data.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
// Look up the existing user id so we can still link.
const existing = await query<{ id: string }>(
`SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1`,
[email],
);
if (existing.rows[0]?.id) {
return { userId: existing.rows[0].id, error: null };
}
return { userId: null, error: "User already exists but could not be looked up." };
}
return {
userId: null,
error: data.message ?? data.code ?? `Sign-up failed (HTTP ${res.status})`,
};
}
const userId = data.user?.id;
if (!userId) {
return { userId: null, error: "Sign-up response missing user.id" };
}
// Flip emailVerified so the user can sign in without a verification step.
// The platform admin already vetted this email — they're handing the
// password to the user directly.
try {
await query(`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`, [userId]);
} catch (verifyErr) {
console.warn(
"[createAdminUser] Failed to set emailVerified=true (non-fatal):",
verifyErr,
);
}
return { userId, error: null };
} catch (err) {
return { user: null, error: err instanceof Error ? err.message : String(err) };
return {
userId: null,
error: err instanceof Error ? err.message : String(err),
};
}
}
@@ -303,17 +566,59 @@ export async function setMustChangePassword(userId: string): Promise<{ success:
}
/**
* No auth service anymore (no Supabase, no Auth.js password-reset
* endpoint). A platform admin can reset access by deleting +
* re-creating the user, or by toggling `must_change_password` via the
* UI the function is preserved as a no-op so call sites keep
* compiling.
* Sends a password-reset email to the user via Neon Auth.
*
* Authorization: platform_admin only. Brand-scoped admins cannot
* trigger password resets for users outside their brand.
*
* The endpoint used (`requestPasswordReset`) is a public Neon Auth
* endpoint it does not require the caller to be the target user.
* For the same reason the public /api/auth/forgot-password route
* works, this will always succeed against the Neon Auth API; we
* still return the result so the UI can show a clear success/failure
* message.
*/
export async function sendPasswordResetEmail(_email: string): Promise<{ success: boolean; error: string | null }> {
export async function sendPasswordResetEmail(
email: string,
): Promise<{ success: boolean; error: string | null }> {
try {
// Authz: must be signed in as a platform_admin.
const me = await getAdminUser();
if (!me) {
return { success: false, error: "Not authenticated." };
}
if (me.role !== "platform_admin") {
return { success: false, error: "Only platform admins can send password resets." };
}
const trimmedEmail = email.trim().toLowerCase();
if (!trimmedEmail) {
return { success: false, error: "Email is required." };
}
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
const result = await neonAuthRequestPasswordReset({
email: trimmedEmail,
redirectTo: `${siteUrl}/reset-password`,
});
if (result?.error) {
console.error("[admin/sendPasswordResetEmail] Neon Auth error:", result.error);
return {
success: false,
error: "Password reset is handled by a platform admin. Contact them to reset your access.",
error: result.error.message ?? result.error.code ?? "Failed to send reset email",
};
}
return { success: true, error: null };
} catch (err) {
console.error("[admin/sendPasswordResetEmail] Unexpected error:", err);
return {
success: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
@@ -327,7 +632,3 @@ export async function getBrands(): Promise<{ brands: { id: string; name: string
return { brands: [], error: err instanceof Error ? err.message : String(err) };
}
}
// Keep `pool` reachable so bundlers don't tree-shake the import — the
// import is for the `server-only` side effect.
void pool;
-77
View File
@@ -1,77 +0,0 @@
"use server";
import { supabase } from "@/lib/supabase";
export type AIAuthConfig = {
provider: string;
api_key: string;
organization_id: string;
base_url: string;
model: string;
max_tokens: number;
};
export async function getAIPreferences(brandId: string): Promise<{
api_key?: string;
organization_id?: string;
base_url?: string;
model?: string;
max_tokens?: number;
} | null> {
const { data, error } = await supabase
.from("brand_ai_settings")
.select("api_key, organization_id, base_url, model, max_tokens")
.eq("brand_id", brandId)
.single();
if (error || !data) return null;
return data;
}
export async function saveAIPreferences(
brandId: string,
config: AIAuthConfig
): Promise<{ success: boolean; error?: string }> {
const result = await supabase
.from("brand_ai_settings")
.upsert({
brand_id: brandId,
provider: config.provider,
api_key: config.api_key || null,
organization_id: config.organization_id || null,
base_url: config.base_url || null,
model: config.model || "gpt-4o-mini",
max_tokens: config.max_tokens || 4000,
updated_at: new Date().toISOString(),
}) as { data: unknown; error: { message: string } | null };
if (result.error) return { success: false, error: result.error.message };
return { success: true };
}
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." };
}
}
+5 -4
View File
@@ -68,9 +68,10 @@ export type ConversionFunnel = {
/**
* Aggregate KPIs over a date window. Replaces the
* `get_reports_summary` SECURITY DEFINER RPC from
* `supabase/migrations/031_reports_v1_rpcs.sql`. Returns zeroed metrics
* if the caller is unauthenticated or no orders exist in the window.
* `get_reports_summary` SECURITY DEFINER RPC (originally from the now-
* archived supabase migrations; consolidated into
* `db/migrations/0001_init.sql`). Returns zeroed metrics if the caller
* is unauthenticated or no orders exist in the window.
*/
async function getReportsSummary(
brandId: string | null,
@@ -284,7 +285,7 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
}>(
`SELECT
o.id::text AS id,
c.name AS customer_name,
TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
o.total_cents::float / 100.0 AS subtotal,
o.status,
o.placed_at::text AS created_at,
+59 -1
View File
@@ -1,7 +1,7 @@
"use server";
import "server-only";
import { signOut } from "@/lib/auth";
import { signIn, signOut } from "@/lib/auth";
import { redirect } from "next/navigation";
/**
@@ -12,3 +12,61 @@ export async function signOutAction(): Promise<void> {
await signOut();
redirect("/login");
}
/**
* Kick off a Google OAuth sign-in. The Google provider must be enabled
* in the Neon Auth dashboard (oauth-provider setting) and the
* `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` env vars must be set
* in the runtime environment.
*
* Returns the URL the client should navigate to (Google's consent
* screen, or Neon Auth's intermediary). The client should do
* `window.location.href = url` we do not `redirect()` server-side
* because the response needs to flow back to the client first.
*/
export async function signInWithGoogleAction(input: {
callbackURL?: string;
errorCallbackURL?: string;
}): Promise<{ url: string | null; error: string | null }> {
const callbackURL = input.callbackURL ?? "/admin";
const errorCallbackURL = input.errorCallbackURL ?? "/login?error=oauth";
try {
// Neon Auth's better-auth client returns `{ data, error }`. For
// social sign-in, `data` is `{ redirect, url, token?, user? }`.
// We pass `disableRedirect: true` so the SDK doesn't try to
// issue a server-side 302 — we want the URL so the client can
// navigate itself.
const result = await signIn.social({
provider: "google",
callbackURL,
errorCallbackURL,
});
if (result.error) {
console.error("[auth/google] signIn.social error:", result.error);
return {
url: null,
error:
result.error.message ??
result.error.code ??
"Could not start Google sign-in.",
};
}
const url = result.data?.url ?? null;
if (!url) {
return {
url: null,
error: "Google sign-in returned no redirect URL.",
};
}
return { url, error: null };
} catch (err) {
console.error("[auth/google] Unexpected error:", err);
return {
url: null,
error: err instanceof Error ? err.message : "An unexpected error occurred.",
};
}
}
+15 -5
View File
@@ -243,14 +243,24 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
}
}
// Public version for storefront pages — uses slug, no auth required
// Public version for storefront pages — uses slug, no auth required.
//
// Inlined as a join against `brands` + `brand_settings` LEFT JOIN
// `wholesale_settings` instead of calling `get_brand_settings_by_slug`.
// That RPC is referenced from this code path but is not defined in any
// shipped migration, so the function-on-the-server call would throw
// `function get_brand_settings_by_slug(unknown) does not exist` and the
// storefront would silently fall back to defaults. The inlined query
// has the same shape and never fails because of a missing RPC.
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
// Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't
// crash the prerender — the page just falls back to its default brand
// name and revalidates from a real request later.
try {
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
"SELECT * FROM get_brand_settings_by_slug($1)",
`SELECT bs.*, b.name AS brand_name, ws.wholesale_enabled
FROM brands b
JOIN brand_settings bs ON bs.brand_id = b.id
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
WHERE b.slug = $1
LIMIT 1`,
[brandSlug],
);
const data = rows[0];
+4 -1
View File
@@ -128,7 +128,7 @@ export async function createOrder(
}
}
await sendOrderReceiptEmail({
const result = await sendOrderReceiptEmail({
customerName,
customerEmail,
orderId: data.id,
@@ -149,6 +149,9 @@ export async function createOrder(
brandName: "Tuxedo Corn",
logoUrl,
});
if (!result.ok) {
console.error("[checkout] Order receipt email failed:", result.error);
}
} catch {
// Email failure should not fail the order
}
+108 -32
View File
@@ -28,6 +28,30 @@ export type DashboardSummary = {
active_products: number;
};
/**
* Mobile-dashboard summary shape. Returned by the `get_dashboard_summary`
* RPC (migration 0091) and consumed by `/admin/v2` to power the four
* stat cards + the 7-day order chart.
*
* - `revenue_today` is in CENTS (not dollars) divide by 100 for display.
* - `orders_last_7_days` is oldest today (7 elements, includes today).
*/
export type MobileDashboardSummary = {
orders_today: number;
revenue_today: number;
pending_fulfillment: number;
stops_today: number;
orders_last_7_days: Array<{ date: string; count: number }>;
};
const EMPTY_MOBILE_DASHBOARD: MobileDashboardSummary = {
orders_today: 0,
revenue_today: 0,
pending_fulfillment: 0,
stops_today: 0,
orders_last_7_days: [],
};
// ── Dashboard Stats Actions ─────────────────────────────────────────────────
export async function getDashboardStats(): Promise<DashboardStats> {
@@ -42,24 +66,22 @@ export async function getDashboardStats(): Promise<DashboardStats> {
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
// Fetch today's orders. `orders` and `stops` use the legacy schema
// (column names like `subtotal`, `brand_id`, `date`); the new-schema
// Drizzle `orders` table doesn't have these. Raw SQL via the shared
// pg pool.
// Fetch today's orders. New schema: `total_cents` (int, divide by 100
// for dollars) and `placed_at` (not legacy `subtotal` / `created_at`).
const todayOrdersRes = brandId
? await pool.query<{ subtotal: number; status: string }>(
`SELECT subtotal, status
? await pool.query<{ total_cents: number; status: string }>(
`SELECT total_cents, status
FROM orders
WHERE created_at >= $1
AND created_at < $2
WHERE placed_at >= $1
AND placed_at < $2
AND brand_id = $3`,
[startOfDay.toISOString(), endOfDay.toISOString(), brandId],
)
: await pool.query<{ subtotal: number; status: string }>(
`SELECT subtotal, status
: await pool.query<{ total_cents: number; status: string }>(
`SELECT total_cents, status
FROM orders
WHERE created_at >= $1
AND created_at < $2`,
WHERE placed_at >= $1
AND placed_at < $2`,
[startOfDay.toISOString(), endOfDay.toISOString()],
);
const todayOrders = todayOrdersRes.rows;
@@ -67,7 +89,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
// Calculate today's revenue and orders
const validOrders = todayOrders.filter((o) => o.status !== "cancelled");
const todayRevenue = validOrders.reduce(
(sum, o) => sum + (o.subtotal || 0),
(sum, o) => sum + ((o.total_cents || 0) / 100),
0,
);
const todayOrderCount = validOrders.length;
@@ -106,7 +128,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
);
const activeProducts = productsRes.rows.length;
// Fetch weekly orders for chart (last 7 days)
// Fetch weekly orders for chart (last 7 days). New schema uses `placed_at`.
const weeklyOrders: number[] = [];
for (let i = 6; i >= 0; i--) {
const dayStart = new Date(startOfDay);
@@ -116,48 +138,60 @@ export async function getDashboardStats(): Promise<DashboardStats> {
const dayRes = brandId
? await pool.query<{ id: string }>(
`SELECT id FROM orders
WHERE created_at >= $1
AND created_at < $2
WHERE placed_at >= $1
AND placed_at < $2
AND brand_id = $3
LIMIT 1`,
[dayStart.toISOString(), dayEnd.toISOString(), brandId],
)
: await pool.query<{ id: string }>(
`SELECT id FROM orders
WHERE created_at >= $1
AND created_at < $2
WHERE placed_at >= $1
AND placed_at < $2
LIMIT 1`,
[dayStart.toISOString(), dayEnd.toISOString()],
);
weeklyOrders.push(dayRes.rows.length > 0 ? 1 : 0);
}
// Fetch recent orders (last 10)
// Fetch recent orders (last 10). New schema: `total_cents`/`placed_at`
// on `orders`; customer name comes from a `customers` join (no
// `customer_name` column on `orders`).
const recentRes = brandId
? await pool.query<{
id: string;
customer_name: string;
subtotal: number;
total_cents: number;
status: string;
created_at: string;
placed_at: string;
}>(
`SELECT id, customer_name, subtotal, status, created_at
FROM orders
WHERE brand_id = $1
ORDER BY created_at DESC
`SELECT o.id::text AS id,
TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
o.total_cents,
o.status,
o.placed_at::text AS placed_at
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.brand_id = $1
ORDER BY o.placed_at DESC
LIMIT 10`,
[brandId],
)
: await pool.query<{
id: string;
customer_name: string;
subtotal: number;
total_cents: number;
status: string;
created_at: string;
placed_at: string;
}>(
`SELECT id, customer_name, subtotal, status, created_at
FROM orders
ORDER BY created_at DESC
`SELECT o.id::text AS id,
TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
o.total_cents,
o.status,
o.placed_at::text AS placed_at
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
ORDER BY o.placed_at DESC
LIMIT 10`,
);
@@ -166,9 +200,9 @@ export async function getDashboardStats(): Promise<DashboardStats> {
.map((o) => ({
id: o.id || "",
customer_name: o.customer_name || "Guest",
total: o.subtotal || 0,
total: (o.total_cents || 0) / 100,
status: o.status || "unknown",
created_at: formatTimeAgo(o.created_at),
created_at: formatTimeAgo(o.placed_at),
}));
return {
@@ -266,6 +300,48 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
}
}
/**
* Fetch the mobile dashboard summary for a single brand.
*
* Different name from the existing `getDashboardSummary()` (which is the
* legacy 4-field shape used by the v1 dashboard) so both can coexist.
* Returns safe fallbacks on any error the v2 page renders a 0s
* dashboard rather than crashing the shell.
*/
export async function getMobileDashboardSummary(
brandId: string,
): Promise<MobileDashboardSummary> {
try {
const adminUser = await getAdminUser();
if (!adminUser) {
return EMPTY_MOBILE_DASHBOARD;
}
const { rows } = await pool.query<{ data: MobileDashboardSummary }>(
"SELECT get_dashboard_summary($1::uuid) AS data",
[brandId],
);
const data = rows[0]?.data;
if (!data) {
return EMPTY_MOBILE_DASHBOARD;
}
return {
orders_today: Number(data.orders_today ?? 0),
revenue_today: Number(data.revenue_today ?? 0),
pending_fulfillment: Number(data.pending_fulfillment ?? 0),
stops_today: Number(data.stops_today ?? 0),
orders_last_7_days: Array.isArray(data.orders_last_7_days)
? data.orders_last_7_days.map((d) => ({
date: String(d?.date ?? ""),
count: Number(d?.count ?? 0),
}))
: [],
};
} catch (error) {
console.error("Failed to fetch mobile dashboard summary:", error);
return EMPTY_MOBILE_DASHBOARD;
}
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function formatTimeAgo(dateString: string): string {
+3 -2
View File
@@ -11,8 +11,9 @@ export type ImportOrdersResult =
/**
* Bulk-import orders. Replaces the legacy `create_order_with_items` SECURITY
* DEFINER RPC (`supabase/migrations/021_shipping_only_brand.sql`). The new
* `orders` schema doesn't have `subtotal`, `customer_name`, `customer_email`,
* DEFINER RPC (originally from the now-archived supabase migrations;
* 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
* `total_cents` and customers are referenced by `customer_id`. For each
* imported order we upsert a `customers` row keyed on email+tenant, then
+88
View File
@@ -0,0 +1,88 @@
"use server";
import { revalidatePath } from "next/cache";
/**
* Offline mutation dispatcher.
*
* The v2 mobile admin (PR 3) introduces an IndexedDB-backed mutation
* queue (src/lib/offline/queue.ts) and a sync engine
* (src/lib/offline/sync.ts) that replays queued actions when the
* device comes back online. The client side enqueues an action with a
* name + JSON payload + idempotency key (clientActionId), then calls
* `syncPending` which invokes this dispatcher for each pending action.
*
* The dispatcher is the single server-side bridge between the offline
* queue and our SECURITY DEFINER server actions. To add a new offline-
* capable action, append an entry to `listClientActions()` with:
* - a stable name (kebab- or camelCase, the client enqueues by name)
* - a handler that accepts the JSON payload and returns
* { success: true } | { success: false; error: string }
*
* The action names referenced by the plan (markOrderReady,
* markOrderPickedUp, updateStopStatus, adjustProductStock) do not yet
* exist as server actions in the v1 codebase. They're added in
* follow-up PRs. For now the dispatcher returns `{ ok: false, error:
* "Unknown action" }` for them, which surfaces in the OfflineBanner
* conflict count. This keeps the structure in place without coupling
* PR 3 to a server-side action surface it doesn't own.
*
* Idempotency: each enqueue carries a clientActionId (uuid v4). Real
* idempotency requires a dedup table on the server that's also a
* follow-up. Until then, replays of the same action will re-execute,
* but last-write-wins on the underlying tables is safe for the
* mutations we ship (status flips, stock adjust).
*/
interface ClientAction {
name: string;
// The handler signature is intentionally loose — payload shapes vary
// per action. The dispatcher in `sync.ts` invokes this with the raw
// payload from IndexedDB.
handler: (payload: unknown, clientActionId: string) => Promise<{
success: boolean;
error?: string;
}>;
}
export async function listClientActions(): Promise<ClientAction[]> {
// Wire real server actions here as they land:
// { name: "markOrderReady", handler: markOrderReady },
// { name: "markOrderPickedUp", handler: markOrderPickedUp },
// { name: "updateStopStatus", handler: updateStopStatus },
// { name: "adjustProductStock", handler: adjustProductStock },
return [];
}
export async function dispatchClientAction(
actionName: string,
payload: unknown,
clientActionId: string,
): Promise<
| { ok: true }
| { ok: false; conflict: string }
| { ok: false; error: string }
> {
const actions = await listClientActions();
const action = actions.find((a) => a.name === actionName);
if (!action) {
return { ok: false, error: "Unknown action" };
}
try {
const result = await action.handler(payload, clientActionId);
if (result.success === false) {
const message = result.error ?? "Action failed";
if (/conflict|stale/i.test(message)) {
return { ok: false, conflict: message };
}
return { ok: false, error: message };
}
revalidatePath("/admin/v2/orders");
revalidatePath("/admin/v2/orders/[id]", "page");
revalidatePath("/admin/v2/stops");
revalidatePath("/admin/v2/products");
return { ok: true };
} catch (err) {
return { ok: false, error: (err as Error).message };
}
}
-149
View File
@@ -1,149 +0,0 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
// ── Types ────────────────────────────────────────────────────────────────────
export type PlatformMetrics = {
active_brands: number;
orders_today: number;
revenue_today: number;
active_routes: number;
failed_orders_today: number;
pending_orders_today: number;
};
export type ActivityEvent = {
id: string;
event_type: string;
entity_type: string;
entity_id: string;
payload: Record<string, unknown>;
actor_type: string;
source: string;
created_at: string;
brand_id: string | null;
brand_name: string | null;
};
export type BrandHealth = {
brand_id: string;
brand_name: string;
brand_slug: string;
plan_tier: string;
orders_today: number;
revenue_today: number;
active_stops: number;
failed_orders: number;
pending_orders: number;
last_activity: string | null;
open_pain_items: number;
health_status: "healthy" | "warning" | "critical";
};
export type PainLogItem = {
id: string;
brand_id: string | null;
brand_name: string | null;
severity: "low" | "medium" | "high" | "critical";
category: string;
title: string;
description: string | null;
status: string;
created_at: string;
};
export type CreatePainLogData = {
brand_id?: string | null;
severity: "low" | "medium" | "high" | "critical";
category: string;
title: string;
description?: string | null;
};
// ── Internal RPC helper ──────────────────────────────────────────────────────
async function platformRPC<T>(rpcName: string): Promise<T> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
if (adminUser.role !== "platform_admin") throw new Error("Access denied: platform admin only");
const { rows } = await pool.query<Record<string, T>>(
`SELECT ${rpcName}() AS "${rpcName}"`,
);
const data = rows[0]?.[rpcName];
if (data == null) {
return null as T;
}
return data as T;
}
// ── Platform actions ─────────────────────────────────────────────────────────
export async function getPlatformMetrics(): Promise<PlatformMetrics> {
return platformRPC<PlatformMetrics>("get_platform_command_center_metrics");
}
export async function getPlatformActivityFeed(): Promise<ActivityEvent[]> {
return platformRPC<ActivityEvent[]>("get_platform_activity_feed");
}
export async function getBrandHealthSnapshot(): Promise<BrandHealth[]> {
return platformRPC<BrandHealth[]>("get_brand_health_snapshot");
}
export async function getPainLog(): Promise<PainLogItem[]> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const { rows } = await pool.query<PainLogItem>(
`SELECT * FROM founder_pain_log_platform ORDER BY created_at DESC`,
);
return rows;
}
export async function createPainLogItem(data: CreatePainLogData): Promise<{ success: boolean; error?: string }> {
try {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
await pool.query(
`INSERT INTO founder_pain_log (brand_id, severity, category, title, description)
VALUES ($1, $2, $3, $4, $5)`,
[
data.brand_id || null,
data.severity,
data.category,
data.title,
data.description || null,
],
);
return { success: true };
} catch (e) {
return { success: false, error: String(e) };
}
}
export async function resolvePainLogItem(id: string): Promise<{ success: boolean; error?: string }> {
try {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
await pool.query(
`UPDATE founder_pain_log
SET status = 'resolved', resolved_at = now(), resolved_by = $2
WHERE id = $1`,
[id, adminUser.id],
);
return { success: true };
} catch (e) {
return { success: false, error: String(e) };
}
}
+2 -1
View File
@@ -73,7 +73,8 @@ export type CampaignActivityRow = {
// ── Helpers ──────────────────────────────────────────────────────────────────
//
// The reports V1 RPCs (`get_reports_summary`, `get_orders_by_stop_report`, etc.)
// from `supabase/migrations/031_reports_v1_rpcs.sql` reference legacy columns
// (originally from the now-archived supabase migrations; consolidated
// into `db/migrations/0001_init.sql`) reference legacy columns
// (`orders.subtotal`, `stops.city/state/date`, `communication_contacts`).
// The SaaS rebuild has a different `orders` schema (`total_cents`, no
// `stop_id`/`city`/`state` columns on `stops`) and a different contacts table
+7 -6
View File
@@ -4,12 +4,13 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
// TODO(migration): the `harvest_lots` / `harvest_lot_events` / `lot_orders`
// tables from the route-trace feature (defined across
// supabase/migrations/143_enable_route_trace.sql and friends) were retired
// from the SaaS rebuild — they don't exist in `db/schema/`. The functions
// below all return empty results so the public trace page and FSMA reports
// 404 gracefully. If route-trace comes back, re-introduce the tables in
// `db/schema/` and replace these stubs with real Drizzle queries.
// tables from the route-trace feature (originally defined in the now-
// archived supabase migrations; consolidated into
// `db/migrations/0001_init.sql`) were retired from the SaaS rebuild —
// they don't exist in `db/schema/`. The functions below all return empty
// results so the public trace page and FSMA reports 404 gracefully. If
// route-trace comes back, re-introduce the tables in `db/schema/` and
// replace these stubs with real Drizzle queries.
export interface HarvestLot {
id: string;
+7 -7
View File
@@ -10,10 +10,10 @@ export type UpdateShippingStatusResult =
// TODO(migration): shipping is dormant in the SaaS rebuild. The legacy
// `shipments` table (with `tracking_number`, `fedex_shipment_id`, etc.),
// the `shipping_status` column on `orders`, and the `update_shipping_order`
// RPC from `supabase/migrations/040_shipping_fulfillment_rpcs.sql` are
// gone. The functions below stub to "not configured" so the admin
// shipping tab renders gracefully. Re-introduce shipping in
// `db/schema/` when the feature is reactivated.
// RPC (originally from the now-archived supabase migrations) are gone.
// The functions below stub to "not configured" so the admin shipping
// tab renders gracefully. Re-introduce shipping in `db/schema/` when
// the feature is reactivated.
export async function updateShippingStatus(
_orderId: string,
@@ -73,9 +73,9 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
}>(
`SELECT
o.id::text AS id,
c.name AS customer_name,
c.email AS customer_email,
c.phone AS customer_phone,
TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
c.primary_email AS customer_email,
c.primary_phone AS customer_phone,
o.status,
o.total_cents::float / 100.0 AS subtotal,
o.placed_at::text AS created_at,
+3 -2
View File
@@ -93,8 +93,9 @@ async function loadShippingSettingsForBrand(brandId: string): Promise<ShippingSe
/**
* Calls the legacy `get_order_items_perishable` SECURITY DEFINER RPC
* via `pool.query`. The function still lives in the database (see
* supabase/migrations/083_shipping_settings.sql).
* via `pool.query`. The function still lives in the database
* (originally from the now-archived supabase migrations; consolidated
* into `db/migrations/0001_init.sql`).
*/
async function isShipmentPerishable(orderId: string): Promise<boolean> {
try {
+6 -6
View File
@@ -11,12 +11,12 @@
*
* TODO(migration): shipping is dormant in the SaaS rebuild. The
* legacy `shipping_settings` table (with the FedEx credential columns
* this file needs) is still present in the database see
* supabase/migrations/083_shipping_settings.sql but the new
* `db/schema/` does not declare it. The data reads below hit the
* legacy table directly via `pool.query`. When shipping comes back,
* declare the table in `db/schema/shipping.ts` and switch the reads
* to Drizzle.
* this file needs) is still present in the database originally from
* the now-archived supabase migrations, consolidated into
* `db/migrations/0001_init.sql` but the new `db/schema/` does not
* declare it. The data reads below hit the legacy table directly via
* `pool.query`. When shipping comes back, declare the table in
* `db/schema/shipping.ts` and switch the reads to Drizzle.
*/
import { getAdminUser } from "@/lib/admin-permissions";
+6 -6
View File
@@ -4,12 +4,12 @@
* Shipping settings CRUD + FedEx connection test.
*
* TODO(migration): shipping is dormant in the SaaS rebuild. The
* `shipping_settings` table is still part of the legacy schema (see
* supabase/migrations/083_shipping_settings.sql) the FedEx integration
* continues to read/write it via raw `pool.query` SQL rather than the
* Supabase REST gateway or Drizzle. When shipping is reactivated, move
* the table declaration into `db/schema/shipping.ts` and switch the
* reads to typed Drizzle queries.
* `shipping_settings` table is still part of the legacy schema
* (originally from the now-archived supabase migrations; consolidated
* into `db/migrations/0001_init.sql`) the FedEx integration continues
* to read/write it via raw `pool.query` SQL rather than Drizzle. When
* shipping is reactivated, move the table declaration into
* `db/schema/shipping.ts` and switch the reads to typed Drizzle queries.
*/
import { getAdminUser } from "@/lib/admin-permissions";
+27
View File
@@ -77,3 +77,30 @@ export async function getSyncLog(brandId: string): Promise<{
return { success: true, logs };
}
/**
* Pending-item count for the dashboard widget badge. Used by
* `SquareSyncWidget` so it can refresh without re-running the full
* sync log query. Returns 0 when the caller isn't authorized never
* throws, so callers can render "0" while the auth check settles.
*/
export async function getSquareQueueCount(brandId: string): Promise<number> {
const adminUser = await getAdminUser();
if (!adminUser) return 0;
try {
assertBrandAccess(adminUser, brandId);
} catch {
return 0;
}
try {
const { rows } = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count
FROM square_sync_queue
WHERE brand_id = $1 AND status = 'pending'`,
[brandId],
);
return Number(rows[0]?.count ?? 0);
} catch {
return 0;
}
}
+7 -6
View File
@@ -4,12 +4,13 @@
* Assign / unassign a product to a stop.
*
* TODO(migration): the `assign_product_to_stop` and
* `unassign_product_from_stop` SECURITY DEFINER RPCs live in
* supabase/migrations/030. The RPCs still exist in the database and
* are called via `pool.query` rather than the Supabase REST gateway.
* When stops/products are re-platformed on the SaaS rebuild, replace
* these with direct inserts/deletes on the new `db/schema/stops.ts`
* `stopProducts` table.
* `unassign_product_from_stop` SECURITY DEFINER RPCs (originally from
* the now-archived supabase migrations; consolidated into
* `db/migrations/0001_init.sql`). The RPCs still exist in the
* database and are called via `pool.query`. When stops/products are
* re-platformed on the SaaS rebuild, replace these with direct
* inserts/deletes on the new `db/schema/stops.ts` `stopProducts`
* table.
*/
import { getAdminUser } from "@/lib/admin-permissions";
+235
View File
@@ -0,0 +1,235 @@
"use server";
/**
* Public storefront data accessors.
*
* Each function hits Postgres directly via the shared `pg` pool from
* `@/lib/db` and is safe to call from public (unauthenticated) pages
* because the SQL filters by `is_public = true` / `active = true` and
* never returns anything the storefront shouldn't expose.
*/
import { pool } from "@/lib/db";
export type StorefrontBrand = {
id: string;
name: string;
slug: string;
};
export type StorefrontStop = {
id: string;
brand_id: string;
name: string | null;
city: string;
state: string;
zip: string | null;
address: string | null;
location: string | null;
date: string;
time: string | null;
cutoff_date: string | null;
status: string;
notes: string | null;
is_public: boolean;
};
export type StorefrontProduct = {
id: string;
brand_id: string;
name: string;
description: string | null;
price: number;
type: string | null;
image_url: string | null;
is_taxable: boolean | null;
pickup_type: string | null;
active: boolean;
};
export type StorefrontData = {
brand: StorefrontBrand | null;
stops: StorefrontStop[];
products: StorefrontProduct[];
};
/**
* Look up a brand by its public slug. Returns `null` if no brand matches
* (which means the storefront slug is wrong, or the brand was renamed).
*/
export async function getStorefrontBrandBySlug(
slug: string,
): Promise<StorefrontBrand | null> {
const { rows } = await pool.query<StorefrontBrand>(
`SELECT id, name, slug
FROM brands
WHERE slug = $1
LIMIT 1`,
[slug],
);
return rows[0] ?? null;
}
/**
* Public stops for a brand storefront. Filters out draft / private stops
* and any stop whose date has already passed.
*/
export async function getStorefrontStops(brandId: string): Promise<StorefrontStop[]> {
const { rows } = await pool.query<StorefrontStop>(
`SELECT id, brand_id, name, city, state, zip, address, location,
date::text AS date, time, cutoff_date::text AS cutoff_date,
status, notes, is_public
FROM stops
WHERE brand_id = $1
AND is_public = true
AND status = 'active'
AND date >= CURRENT_DATE
ORDER BY date ASC, time ASC NULLS LAST`,
[brandId],
);
return rows;
}
/**
* Active products visible on the storefront. Same shape as the admin
* product list but without the brand-internal columns (cost, supplier, ).
*
* Note: products.price is stored in cents (NUMERIC). We return it as a
* float in **dollars** so callers can format directly as `$${price}`
* without doing the cents-to-dollars math.
*/
export async function getStorefrontProducts(
brandId: string,
): Promise<StorefrontProduct[]> {
const { rows } = await pool.query<StorefrontProduct>(
`SELECT id, brand_id, name, description,
price::float8 / 100 AS price,
type, image_url, is_taxable, pickup_type, active
FROM products
WHERE brand_id = $1
AND active = true
AND deleted_at IS NULL
ORDER BY name ASC`,
[brandId],
);
return rows;
}
/**
* Single server action that the storefront Client Components call from
* their initial `useEffect`. Bundles brand lookup + public stops +
* active products into one round-trip-friendly call.
*
* Returns `{ brand: null, stops: [], products: [] }` if the slug doesn't
* match any brand pages fall back to default copy in that case.
*/
export async function getStorefrontData(slug: string): Promise<StorefrontData> {
const brand = await getStorefrontBrandBySlug(slug);
if (!brand) return { brand: null, stops: [], products: [] };
const [stops, products] = await Promise.all([
getStorefrontStops(brand.id),
getStorefrontProducts(brand.id),
]);
return { brand, stops, products };
}
export type StorefrontWholesaleSettings = {
invoice_business_name: string | null;
};
/**
* Public wholesale-settings record for a brand just the invoice
* business name used by the storefront contact page. Returns `null`
* if the brand or its wholesale settings row doesn't exist.
*
* NOTE: only `invoice_business_name` is exposed; other invoice-contact
* fields don't exist on `wholesale_settings` yet and shouldn't be
* surfaced here. If you need them, add a migration first.
*/
export async function getStorefrontWholesaleSettings(
slug: string,
): Promise<StorefrontWholesaleSettings | null> {
const brand = await getStorefrontBrandBySlug(slug);
if (!brand) return null;
const { rows } = await pool.query<StorefrontWholesaleSettings>(
`SELECT invoice_business_name
FROM wholesale_settings
WHERE brand_id = $1
LIMIT 1`,
[brand.id],
);
return rows[0] ?? null;
}
export type WholesalePortalConfig = {
wholesale_enabled: boolean | null;
online_payment_enabled: boolean | null;
};
/**
* Public wholesale portal flags for a brand. Used by the customer-facing
* `/wholesale/portal` page to decide whether to allow sign-in and whether
* to show the online-payment button. Doesn't require auth (the portal
* already validated the session before this is called).
*/
export async function getWholesalePortalConfig(
brandId: string,
): Promise<WholesalePortalConfig | null> {
const { rows } = await pool.query<WholesalePortalConfig>(
`SELECT wholesale_enabled, online_payment_enabled
FROM wholesale_settings
WHERE brand_id = $1
LIMIT 1`,
[brandId],
);
return rows[0] ?? null;
}
export type BrandName = { name: string };
/**
* Fetch a brand's display name by id. Public used by the wholesale
* portal header to render the brand name without leaking any other
* brand columns (logo, plan, stripe keys, etc.).
*/
export async function getStorefrontBrandName(
brandId: string,
): Promise<string | null> {
const { rows } = await pool.query<BrandName>(
`SELECT name FROM brands WHERE id = $1 LIMIT 1`,
[brandId],
);
return rows[0]?.name ?? null;
}
/**
* Look up a single public stop by its id, plus the brand's active
* products. Used by `/[brand]/stops/[id]` pages.
*
* NOTE: The URL parameter is named `[id]` (not `[slug]`) because the
* `stops` table has no slug column. URLs use the stop's UUID.
*
* Returns `null` if the stop is not public, not active, or its date has
* already passed. Returns the empty product array if the brand has no
* active products at the moment.
*/
export async function getStorefrontStopById(id: string): Promise<{
stop: StorefrontStop;
products: StorefrontProduct[];
} | null> {
const { rows: stopRows } = await pool.query<StorefrontStop>(
`SELECT id, brand_id, name, city, state, zip, address, location,
date::text AS date, time, cutoff_date::text AS cutoff_date,
status, notes, is_public
FROM stops
WHERE id = $1
AND is_public = true
AND status = 'active'
LIMIT 1`,
[id],
);
const stop = stopRows[0];
if (!stop) return null;
const products = await getStorefrontProducts(stop.brand_id);
return { stop, products };
}
+6 -6
View File
@@ -152,12 +152,12 @@ async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings |
//
// TODO(migration): the tax dashboard reads from the legacy `orders`
// table via the `get_tax_summary` and `get_taxable_orders` SECURITY
// DEFINER RPCs (supabase/migrations/095). Both the table and the RPCs
// still exist; we call the RPCs through `pool.query` rather than the
// Supabase REST gateway. When the SaaS rebuild's orders table grows a
// `tax_amount` column or the tax dashboard is rebuilt against the new
// schema, these helpers should be rewritten against the Drizzle
// `orders` table directly.
// DEFINER RPCs (originally from the now-archived supabase migrations;
// consolidated into `db/migrations/0001_init.sql`). Both the table and
// the RPCs still exist; we call the RPCs through `pool.query`. When the
// SaaS rebuild's orders table grows a `tax_amount` column or the tax
// dashboard is rebuilt against the new schema, these helpers should be
// rewritten against the Drizzle `orders` table directly.
export type TaxByStateRow = {
state: string;
File diff suppressed because it is too large Load Diff
+398 -61
View File
@@ -1,88 +1,374 @@
/**
* Water Log field (PIN) actions.
*
* These power `/water` and `/water/admin` the mobile-first, PIN-only
* portals that ditch-riders actually use in the field. The shape of
* the data they consume is the same as admin actions, but the
* authorization model is different:
*
* - Irrigators carry a `wl_session` cookie tied to a row in
* `water_sessions`. Cookie expiry = row's `expires_at`.
* - Admins (water_admin role) carry a `wl_admin_session` cookie
* tied to `water_admin_sessions`.
*
* Both cookies are httpOnly + sameSite=lax. The session lookup is the
* gate for every mutating action there's no role/permission check
* inside the action because the cookie presence + the row's role is
* the permission.
*/
"use server";
import { cookies } from "next/headers";
import { and, desc, eq, gte, sql } from "drizzle-orm";
import { withBrand, withPlatformAdmin } from "@/db/client";
import {
waterHeadgates,
waterIrrigators,
waterSessions,
waterLogEntries,
waterAdminSessions,
} from "@/db/schema/water-log";
import { verifyPin, validatePin } from "@/lib/water-log-pin";
import { logAlert } from "@/lib/water-log-audit";
// TODO(migration): the water-log field UI used a chain of Supabase RPCs
// (`get_water_headgates`, `verify_water_pin`, `get_water_user_by_id`,
// `submit_water_entry`, `trigger_water_alert`,
// `get_water_admin_session`) and tables (`water_headgates`,
// `water_users`, `water_sessions`, `water_admin_sessions`,
// `water_entries`, `water_alert_log`) that are not in the SaaS
// rebuild's `db/schema/`. The actions below preserve the original
// signatures and return empty / no-op responses so the field UI
// degrades gracefully. See `actions/route-trace/lots.ts` for the
// same pattern.
// Field sessions last 8h — a working day in the field. Long enough
// that a single sign-in covers a morning shift + afternoon shift.
const FIELD_SESSION_HOURS = 8;
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
type VerifyPinResult = {
// ── Types ──────────────────────────────────────────────────────────────────
type FieldHeadgate = {
id: string;
name: string;
unit: string;
status: string;
high_threshold: number | null;
low_threshold: number | null;
active: boolean;
};
type VerifyPinResult =
| {
success: true;
user_id: string;
name: string;
role: string;
role: "irrigator" | "water_admin";
session_id: string;
lang: string;
} | {
success: false;
error: string;
};
}
| { success: false; error: string };
type SubmitEntryResult = {
success: true;
entry_id: string;
} | {
success: false;
error: string;
};
type SubmitEntryResult =
| { success: true; entry_id: string }
| { success: false; error: string };
type Headgate = {
id: string;
name: string;
active: boolean;
created_at: string;
};
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
// ── Headgates (read-only for field) ────────────────────────────────────────
export async function getWaterHeadgates(
_brandId: string,
_activeOnly = false
): Promise<Headgate[]> {
return [];
activeOnly: boolean = false,
): Promise<FieldHeadgate[]> {
return withBrand(TUXEDO_BRAND_ID, async (db) => {
const where = activeOnly
? and(
eq(waterHeadgates.brandId, TUXEDO_BRAND_ID),
eq(waterHeadgates.active, true),
)
: eq(waterHeadgates.brandId, TUXEDO_BRAND_ID);
const rows = await db
.select()
.from(waterHeadgates)
.where(where)
.orderBy(waterHeadgates.name);
return rows.map((h) => ({
id: h.id,
name: h.name,
unit: h.unit,
status: h.status,
high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null,
low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null,
active: h.active,
}));
});
}
// ── PIN verify + session ───────────────────────────────────────────────────
export async function verifyWaterPin(
_brandId: string,
_pin: string
pin: string,
): Promise<VerifyPinResult> {
return { success: false, error: NOT_CONFIGURED };
}
// Validate format first (cheap, no DB).
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
export async function submitWaterEntry(
_headgateId: string,
_measurement: number,
_unit: string,
_notes: string,
_photoUrl?: string,
_latitude?: number,
_longitude?: number,
_headgateLocked?: boolean
): Promise<SubmitEntryResult> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
// Look the irrigator up across all brands (PIN is the only credential).
const lookup = await withPlatformAdmin(async (db) => {
const rows = await db
.select()
.from(waterIrrigators)
.where(eq(waterIrrigators.active, true))
.orderBy(waterIrrigators.name);
return rows;
});
if (!sessionId) {
return { success: false, error: "Not logged in" };
// Constant-time-ish: check every row, only succeed on the first match.
// (We can't make this perfectly constant-time without pulling the
// hashed values into memory in random order, but iterating by name
// is close enough for a 4-digit PIN and avoids the worst case of an
// obvious timing oracle on a sorted list.)
const match = lookup.find((i) => verifyPin(pin, i.pinHash));
if (!match) {
// Add a small delay to discourage brute-force scanning.
await new Promise((r) => setTimeout(r, 200));
return { success: false, error: "Invalid PIN" };
}
return { success: false, error: NOT_CONFIGURED };
// Create a session in the user's brand context.
const sessionId = await withBrand(match.brandId, async (db) => {
const expiresAt = new Date(
Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000,
);
const [row] = await db
.insert(waterSessions)
.values({
irrigatorId: match.id,
expiresAt,
})
.returning({ id: waterSessions.id });
if (!row) throw new Error("Failed to create session");
// Bump last_used_at for "I haven't seen this person in a while" UX
await db
.update(waterIrrigators)
.set({ lastUsedAt: new Date() })
.where(eq(waterIrrigators.id, match.id));
return row.id;
});
const cookieStore = await cookies();
cookieStore.set("wl_session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: FIELD_SESSION_HOURS * 3600,
path: "/",
});
return {
success: true,
user_id: match.id,
name: match.name,
role: (match.role as "irrigator" | "water_admin") ?? "irrigator",
session_id: sessionId,
lang: match.languagePreference,
};
}
// ── Submit entry ───────────────────────────────────────────────────────────
export async function submitWaterEntry(
headgateId: string,
measurement: number,
unit: string,
notes: string,
photoUrl?: string,
latitude?: number,
longitude?: number,
_headgateLocked?: boolean,
): Promise<SubmitEntryResult> {
// ── Auth ──
const session = await requireFieldSession();
if (!session.ok) return { success: false, error: session.error };
// ── Validate input ──
if (!Number.isFinite(measurement) || measurement < 0) {
return { success: false, error: "Measurement must be a non-negative number" };
}
if (measurement > 1_000_000) {
return { success: false, error: "Measurement is implausibly large" };
}
if (notes && notes.length > 500) {
return { success: false, error: "Notes are too long (max 500 chars)" };
}
if (latitude != null && (latitude < -90 || latitude > 90)) {
return { success: false, error: "Invalid latitude" };
}
if (longitude != null && (longitude < -180 || longitude > 180)) {
return { success: false, error: "Invalid longitude" };
}
// ── Write the entry inside the brand context ──
return withBrand(session.brandId, async (db) => {
// Confirm the headgate belongs to this brand
const headgateRows = await db
.select()
.from(waterHeadgates)
.where(eq(waterHeadgates.id, headgateId))
.limit(1);
const headgate = headgateRows[0];
if (!headgate) return { success: false, error: "Headgate not found" };
// Auto-calc total_gallons when CFS × duration is provided.
// Caller passes `measurement` as the total CFS volume for the set
// (we don't track duration separately on the entry — duration goes
// in notes if needed). We leave total_gallons null for CFS by
// default to avoid spurious "X gallons" claims.
const totalGallons = computeTotalGallons(measurement, unit);
const now = new Date();
const loggedDate = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}`;
const [entry] = await db
.insert(waterLogEntries)
.values({
brandId: session.brandId,
headgateId,
irrigatorId: session.userId,
measurement: measurement.toString(),
unit,
totalGallons: totalGallons?.toString() ?? null,
method: "manual",
notes: notes || null,
submittedVia: "field",
photoUrl: photoUrl ?? null,
latitude: latitude ?? null,
longitude: longitude ?? null,
loggedDate,
loggedAt: now,
})
.returning({ id: waterLogEntries.id });
if (!entry) return { success: false, error: "Insert failed" };
// Bump headgate last_used_at
await db
.update(waterHeadgates)
.set({ lastUsedAt: now })
.where(eq(waterHeadgates.id, headgateId));
// Threshold alert check (fire-and-forget)
if (headgate.highThreshold != null && measurement > Number(headgate.highThreshold)) {
void logAlert({
brandId: session.brandId,
headgateId,
alertType: "high",
reading: measurement,
threshold: Number(headgate.highThreshold),
headgateName: headgate.name,
});
}
if (headgate.lowThreshold != null && measurement < Number(headgate.lowThreshold)) {
void logAlert({
brandId: session.brandId,
headgateId,
alertType: "low",
reading: measurement,
threshold: Number(headgate.lowThreshold),
headgateName: headgate.name,
});
}
return { success: true, entry_id: entry.id };
});
}
// ── Session helpers (shared with admin.ts via requireFieldSession) ────────
type FieldSession =
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
| { ok: false; error: string };
export async function requireFieldSession(): Promise<FieldSession> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (!sessionId) return { ok: false, error: "Not logged in" };
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
session: waterSessions,
irrigator: waterIrrigators,
})
.from(waterSessions)
.innerJoin(waterIrrigators, eq(waterIrrigators.id, waterSessions.irrigatorId))
.where(eq(waterSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return { ok: false as const, error: "Session not found" };
if (row.session.expiresAt.getTime() < Date.now()) {
// Best-effort cleanup
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
return { ok: false as const, error: "Session expired" };
}
if (!row.irrigator.active) {
return { ok: false as const, error: "User is inactive" };
}
return {
ok: true as const,
userId: row.irrigator.id,
brandId: row.irrigator.brandId,
role: (row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
}
export async function getFieldSessionUser(): Promise<{
userId: string;
name: string;
brandId: string;
role: "irrigator" | "water_admin";
} | null> {
const s = await requireFieldSession();
if (!s.ok) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ name: waterIrrigators.name, role: waterIrrigators.role })
.from(waterIrrigators)
.where(eq(waterIrrigators.id, s.userId))
.limit(1);
const row = rows[0];
if (!row) return null;
return {
userId: s.userId,
name: row.name,
brandId: s.brandId,
role: (row.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
}
// ── Cookie/language helpers ───────────────────────────────────────────────
export async function logoutWater(): Promise<void> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (sessionId) {
// Best-effort DB cleanup
try {
await withPlatformAdmin(async (db) => {
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
});
} catch {
// ignore — the cookie is being deleted anyway
}
}
cookieStore.delete("wl_session");
}
export async function logoutWaterAdmin(): Promise<void> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (sessionId) {
try {
await withPlatformAdmin(async (db) => {
await db
.delete(waterAdminSessions)
.where(eq(waterAdminSessions.id, sessionId));
});
} catch {
// ignore — cookie is being deleted anyway
}
}
cookieStore.delete("wl_admin_session");
}
@@ -91,7 +377,49 @@ export async function getWaterSession(): Promise<string | null> {
return cookieStore.get("wl_session")?.value ?? null;
}
/**
* Resolves the current `/water/admin` session.
*
* Returns the admin role + brandId on success, or `null` if the cookie
* is missing / expired / points at a deleted session row. Used by
* admin pages (entries/[id], headgates/[id], users/[id]) as a *second*
* gate on top of `getAdminUser()` a site admin can edit entries
* directly, but a PIN-authenticated water admin can too.
*/
export async function getWaterAdminSession(): Promise<{
sessionId: string;
brandId: string;
adminUserId: string;
role: "water_admin";
} | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
id: waterAdminSessions.id,
brandId: waterAdminSessions.brandId,
adminUserId: waterAdminSessions.adminUserId,
expiresAt: waterAdminSessions.expiresAt,
})
.from(waterAdminSessions)
.where(eq(waterAdminSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.expiresAt.getTime() <= Date.now()) return null;
return {
sessionId: row.id,
brandId: row.brandId,
adminUserId: row.adminUserId,
role: "water_admin" as const,
};
});
}
export async function setWaterLang(lang: string): Promise<void> {
if (!["en", "es"].includes(lang)) return;
const cookieStore = await cookies();
cookieStore.set("wl_lang", lang, {
httpOnly: false,
@@ -102,15 +430,24 @@ export async function setWaterLang(lang: string): Promise<void> {
});
}
export async function getWaterAdminSession(): Promise<{
user_id: string;
name: string;
role: string;
} | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return null;
// ── Internal helpers ──────────────────────────────────────────────────────
/**
* If the measurement is in CFS (cubic feet per second) and we know it
* represents a set duration, we can derive total gallons. Without
* duration, we leave total_gallons null to avoid making up numbers.
*
* CFS × 60s × 7.48052 = gallons per minute.
* 1 CFS for 1 hour = ~448.83 gallons.
*/
function computeTotalGallons(measurement: number, unit: string): number | null {
if (unit !== "CFS") return null;
// We don't have a duration field on the entry, so we return null. The
// admin can edit an entry to set gallons manually if they have that
// data. The hook is here so callers can extend easily.
return null;
}
function pad(n: number): string {
return n.toString().padStart(2, "0");
}
+227 -27
View File
@@ -1,44 +1,244 @@
/**
* Water Log admin settings (PIN-protected /water/admin portal).
*
* The `/water/admin` portal is gated by its own 4-digit PIN (separate
* from the irrigators' PIN). That PIN is *hashed* and stored in
* `water_admin_settings` (one row per brand). Sessions are tracked in
* `water_admin_sessions`.
*
* There is currently no per-admin-user PIN a single brand-wide admin
* PIN. If you need per-user PINs (e.g. for an audit trail of which
* admin entered the portal), swap `pin_hash` for `admin_user_pins` and
* key sessions by `admin_user_id`.
*/
"use server";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import {
waterAdminSettings,
waterAdminSessions,
type WaterAdminSettings,
} from "@/db/schema/water-log";
import { getAdminUser } from "@/lib/admin-permissions";
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
import { logAuditEvent } from "@/lib/water-log-audit";
// TODO(migration): the water-log settings RPCs (`get_water_admin_settings`,
// `hash_water_admin_pin`, `save_water_admin_settings`,
// `verify_water_admin_pin`) and the underlying
// `water_admin_settings` table are not in the SaaS rebuild schema.
// The functions below preserve the original signatures and return
// empty / no-op responses. Same pattern as
// `actions/route-trace/lots.ts`.
export type WaterAdminSettings = {
export type AdminSettings = {
enabled: boolean;
session_duration_hours: number;
can_edit_entries: boolean;
can_delete_entries: boolean;
can_export_csv: boolean;
alert_phone?: string | null;
alerts_enabled?: boolean;
sessionDurationHours: number;
canEditEntries: boolean;
canDeleteEntries: boolean;
canExportCsv: boolean;
alertPhone: string | null;
alertsEnabled: boolean;
/** The currently configured PIN, in plain text. Only returned on
* read right after the admin regenerates it; otherwise null. */
pin: string | null;
};
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
function mapSettings(s: WaterAdminSettings): AdminSettings {
return {
enabled: s.enabled,
sessionDurationHours: s.sessionDurationHours,
canEditEntries: s.canEditEntries,
canDeleteEntries: s.canDeleteEntries,
canExportCsv: s.canExportCsv,
alertPhone: s.alertPhone,
alertsEnabled: s.alertsEnabled,
pin: null, // never returned unless just regenerated
};
}
export async function getWaterAdminSettings(_brandId: string): Promise<WaterAdminSettings | null> {
export async function getWaterAdminSettings(
brandId: string,
): Promise<AdminSettings | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return null;
}
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterAdminSettings)
.where(eq(waterAdminSettings.brandId, brandId))
.limit(1);
if (!rows[0]) {
// Lazy-initialize default settings the first time the page is hit
const [created] = await db
.insert(waterAdminSettings)
.values({ brandId })
.returning();
if (!created) return null;
return { ...mapSettings(created), pin: null };
}
return mapSettings(rows[0]);
});
}
export async function saveWaterAdminSettings(
_brandId: string,
_settings: Partial<WaterAdminSettings & { pin?: string }>
): Promise<{ success: boolean; error?: string }> {
brandId: string,
settings: Partial<AdminSettings>,
): Promise<{ success: boolean; settings?: AdminSettings; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
return { success: false, error: NOT_CONFIGURED };
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { success: false, error: "Not authorized" };
}
if (
settings.sessionDurationHours != null &&
(settings.sessionDurationHours < 1 || settings.sessionDurationHours > 168)
) {
return { success: false, error: "Session duration must be 1168 hours" };
}
return withBrand(brandId, async (db) => {
const update: Partial<WaterAdminSettings> = {};
if (settings.enabled != null) update.enabled = settings.enabled;
if (settings.sessionDurationHours != null)
update.sessionDurationHours = settings.sessionDurationHours;
if (settings.canEditEntries != null)
update.canEditEntries = settings.canEditEntries;
if (settings.canDeleteEntries != null)
update.canDeleteEntries = settings.canDeleteEntries;
if (settings.canExportCsv != null)
update.canExportCsv = settings.canExportCsv;
if (settings.alertPhone !== undefined)
update.alertPhone = settings.alertPhone;
if (settings.alertsEnabled != null)
update.alertsEnabled = settings.alertsEnabled;
update.updatedBy = adminUser.user_id ?? adminUser.id ?? null;
// Upsert: insert if missing, update if present.
const updated = await db
.insert(waterAdminSettings)
.values({ brandId, ...update })
.onConflictDoUpdate({
target: waterAdminSettings.brandId,
set: update,
})
.returning();
await logAuditEvent({
brandId,
actorId: adminUser.user_id ?? null,
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
action: "update",
entityType: "settings",
details: { ...update },
});
return { success: true, settings: mapSettings(updated[0]) };
});
}
export async function verifyWaterAdminPin(
_brandId: string,
_pin: string
): Promise<{ success: boolean; session_id?: string; error?: string }> {
return { success: false, error: NOT_CONFIGURED };
/**
* Generate a fresh PIN, hash it, and save it on the brand. Returns the
* plaintext PIN *once* caller is responsible for showing it to the
* admin and never persisting it.
*/
export async function regenerateAdminPin(
brandId: string,
): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { success: false, error: "Not authorized" };
}
const pin = generatePin();
const pinHash = hashPin(pin);
return withBrand(brandId, async (db) => {
await db
.insert(waterAdminSettings)
.values({ brandId, pinHash })
.onConflictDoUpdate({
target: waterAdminSettings.brandId,
set: { pinHash, updatedBy: adminUser.user_id ?? adminUser.id ?? null },
});
// Invalidate any existing admin sessions for safety
await db
.delete(waterAdminSessions)
.where(eq(waterAdminSessions.brandId, brandId));
await logAuditEvent({
brandId,
actorId: adminUser.user_id ?? null,
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
action: "regenerate_admin_pin",
entityType: "settings",
});
return { success: true, pin };
});
}
/**
* PIN verify for the `/water/admin` portal. Creates a session row and
* sets the `wl_admin_session` cookie.
*/
export async function verifyWaterAdminPin(
brandId: string,
pin: string,
): Promise<{ success: boolean; session_id?: string; error?: string }> {
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterAdminSettings)
.where(eq(waterAdminSettings.brandId, brandId))
.limit(1);
const settings = rows[0];
if (!settings) {
return { success: false, error: "Admin portal not configured" };
}
if (!settings.enabled) {
return { success: false, error: "Admin portal is disabled" };
}
if (!settings.pinHash) {
return { success: false, error: "No PIN configured — generate one in /admin/water-log/settings" };
}
if (!verifyPin(pin, settings.pinHash)) {
await new Promise((r) => setTimeout(r, 200));
return { success: false, error: "Invalid PIN" };
}
// Tie the session to the calling site admin (best-effort).
const adminUser = await getAdminUser();
const expiresAt = new Date(
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
);
const [session] = await db
.insert(waterAdminSessions)
.values({
brandId,
adminUserId: adminUser?.user_id ?? adminUser?.id ?? "00000000-0000-0000-0000-000000000000",
pinHashUsed: settings.pinHash,
expiresAt,
})
.returning({ id: waterAdminSessions.id });
if (!session) return { success: false, error: "Failed to create session" };
const cookieStore = await cookies();
cookieStore.set("wl_admin_session", session.id, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: settings.sessionDurationHours * 3600,
path: "/",
});
return { success: true, session_id: session.id };
});
}
-888
View File
@@ -1,888 +0,0 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
export type WholesaleOrder = {
id: string;
customer_id: string;
status: string;
fulfillment_status: string;
payment_status: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_required: number;
deposit_paid: number;
balance_due: number;
created_at: string;
updated_at: string;
invoice_number: string | null;
assigned_employee_id: string | null;
company_name: string;
contact_name: string | null;
customer_email: string;
customer_phone: string | null;
items: Array<{
id: string;
product_name: string;
quantity: number;
unit_price: number;
line_total: number;
}>;
fulfilled_at: string | null;
};
export type WholesaleCustomer = {
id: string;
user_id: string | null;
company_name: string;
contact_name: string | null;
email: string;
phone: string | null;
account_status: string;
credit_limit: number;
deposits_enabled: boolean;
deposit_threshold: number | null;
deposit_percentage: number | null;
order_email: string | null;
invoice_email: string | null;
admin_notes: string | null;
role: string;
created_at: string;
deleted_at: string | null;
};
export type WholesaleProduct = {
id: string;
name: string;
description: string | null;
unit_type: string;
unit_type_custom: string | null;
availability: string;
qty_available: number;
season_start: string | null;
season_end: string | null;
price_tiers: Array<{ min_qty: number; max_qty: number; price: number }>;
hp_sku: string | null;
hp_item_id: string | null;
handling_instructions: string | null;
storage_warning: string | null;
loading_notes: string | null;
product_label: string | null;
pack_style: string | null;
container_type: string | null;
container_size_code: string | null;
units_per_container: number | null;
default_pickup_location: string | null;
created_at: string;
deleted_at: string | null;
};
export type NotificationRecipient = {
email: string;
name?: string;
active: boolean;
notification_types?: (
| "order_confirmation"
| "deposit_received"
| "order_fulfilled"
| "price_sheet"
| "unclaimed_pickup"
)[];
};
export type WholesaleSettings = {
id: string;
brand_id: string;
portal_page_id: string | null;
price_sheet_page_id: string | null;
require_approval: boolean;
min_order_amount: number | null;
online_payment_enabled: boolean;
wholesale_enabled: boolean;
square_sync_enabled: boolean;
pickup_location: string;
fob_location: string;
from_email: string;
invoice_business_name: string;
invoice_business_address: string | null;
invoice_business_phone: string | null;
invoice_business_email: string | null;
invoice_business_website: string | null;
notification_email: string | null;
notification_recipients: NotificationRecipient[];
last_invoice_number: number;
};
export type WholesaleDashboardStats = {
open_orders: number;
pickup_today: number;
past_due: number;
total_unpaid: number;
awaiting_deposit: number;
fulfilled_today: number;
};
/**
* Resolves the effective brand_id for an action, enforcing brand scoping.
*
* platform_admin null (means "all brands" passes to RPC unchanged)
* brand_admin their own brand_id only; rejects attempts to operate on other brands
* store_employee their own brand_id
* multi_brand_admin active brand from cookie/URL/legacy, must be in brand_ids
* unauthenticated null (actions should already bail out earlier)
*
* This prevents brand_admin from seeing or modifying another brand's data
* even if they manually pass a different brandId to the action.
*/
async function resolveBrandId(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): Promise<string | null> {
if (!adminUser) return null;
if (adminUser.role === "platform_admin") {
// platform_admin can operate on all brands — pass null (= all brands) to RPC
return null;
}
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && activeBrandId !== requestedBrandId) {
// Brand admin trying to operate on another brand's data — block it
return null; // caller should check and return unauthorized
}
return activeBrandId;
}
/**
* Like resolveBrandId but returns null for platform_admin AND throws an error
* if a brand_admin tries to operate outside their brand.
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
*/
async function enforceBrandScope(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): Promise<{ brandId: string | null; error?: string }> {
if (!adminUser) return { brandId: null, error: "Not authenticated" };
if (adminUser.role === "platform_admin") {
return { brandId: null }; // unrestricted
}
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && activeBrandId !== requestedBrandId) {
return { brandId: null, error: "Not authorized to operate on this brand" };
}
return { brandId: activeBrandId };
}
// ── Orders ──────────────────────────────────────────────────────────────────
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleOrder>(
"SELECT * FROM get_wholesale_orders($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleOrder>(
"SELECT * FROM get_wholesale_pickup_orders($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function getWholesaleDashboardStats(brandId?: string): Promise<WholesaleDashboardStats> {
const orders = await getWholesaleOrders(brandId);
const today = new Date().toISOString().split("T")[0];
const open = orders.filter(o => ["pending", "awaiting_deposit", "confirmed"].includes(o.status));
const pickupToday = orders.filter(o => o.anticipated_pickup_date === today && o.fulfillment_status !== "fulfilled");
const pastDue = orders.filter(o => o.anticipated_pickup_date && o.anticipated_pickup_date < today && o.fulfillment_status !== "fulfilled");
const totalUnpaid = open.reduce((sum, o) => sum + Number(o.balance_due), 0);
const awaitingDep = orders.filter(o => o.status === "awaiting_deposit");
const fulfilledToday = orders.filter(o => o.fulfillment_status === "fulfilled" && o.updated_at?.startsWith(today));
return {
open_orders: open.length,
pickup_today: pickupToday.length,
past_due: pastDue.length,
total_unpaid: totalUnpaid,
awaiting_deposit: awaitingDep.length,
fulfilled_today: fulfilledToday.length,
};
}
export async function markWholesaleOrderFulfilled(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query(
"SELECT * FROM mark_wholesale_order_fulfilled($1, $2)",
[orderId, adminUser.user_id]
);
if (!rows || rows.length === 0) {
return { success: false, error: "Failed to mark fulfilled" };
}
} catch {
return { success: false, error: "Failed to mark fulfilled" };
}
enqueueWholesaleWebhookForOrderFulfilled(orderId, resolved ?? undefined).catch(() => {});
return { success: true };
}
async function enqueueWholesaleWebhookForOrderFulfilled(orderId: string, brandId?: string) {
try {
await pool.query(
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
[
"order_fulfilled",
orderId,
brandId ?? null,
JSON.stringify({ order_id: orderId }),
]
);
} catch (_) {}
}
export async function updateWholesaleOrderStatus(
orderId: string,
status: "pending" | "confirmed" | "cancelled",
brandId?: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
await pool.query(
"SELECT * FROM update_wholesale_order_status($1, $2)",
[orderId, status]
);
return { success: true };
} catch {
return { success: false, error: "Failed to update order status" };
}
}
export async function deleteWholesaleOrder(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
await pool.query(
"SELECT * FROM delete_wholesale_order($1)",
[orderId]
);
return { success: true };
} catch {
return { success: false, error: "Failed to delete order" };
}
}
export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM delete_wholesale_customer($1)",
[customerId]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Delete failed" };
}
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete customer" };
}
}
export async function deleteWholesaleProduct(productId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM delete_wholesale_product($1)",
[productId]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Delete failed" };
}
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete product" };
}
}
// ── Customers ────────────────────────────────────────────────────────────────
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleCustomer>(
"SELECT * FROM get_wholesale_customers($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function saveWholesaleCustomer(params: {
brandId: string;
userId?: string;
companyName?: string;
contactName?: string;
email?: string;
phone?: string;
accountStatus?: string;
creditLimit?: number;
depositsEnabled?: boolean;
depositThreshold?: number;
depositPercentage?: number;
orderEmail?: string;
invoiceEmail?: string;
adminNotes?: string;
}): Promise<{ success: boolean; error?: string; id?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT * FROM upsert_wholesale_customer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
[
params.brandId,
params.userId ?? null,
params.companyName ?? null,
params.contactName ?? null,
params.email ?? null,
params.phone ?? null,
params.accountStatus ?? "active",
params.creditLimit ?? 0,
params.depositsEnabled ?? false,
params.depositThreshold ?? null,
params.depositPercentage ?? null,
params.orderEmail ?? null,
params.invoiceEmail ?? null,
params.adminNotes ?? null,
]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, id: data?.id };
} catch {
return { success: false, error: "Failed to save customer" };
}
}
// ── Products ─────────────────────────────────────────────────────────────────
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleProduct>(
"SELECT * FROM get_wholesale_products($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function saveWholesaleProduct(params: {
brandId: string;
id?: string;
name: string;
description?: string;
unitType?: string;
availability?: string;
qtyAvailable?: number;
priceTiers?: Array<{ min_qty: number; max_qty: number; price: number }>;
hpSku?: string;
hpItemId?: string;
internalNotes?: string;
handlingInstructions?: string;
storageWarning?: string;
productLabel?: string;
packStyle?: string;
containerType?: string;
defaultPickupLocation?: string;
}): Promise<{ success: boolean; error?: string; id?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT * FROM upsert_wholesale_product($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
[
params.brandId,
params.id ?? null,
params.name,
params.description ?? null,
params.unitType ?? "each",
params.availability ?? "unavailable",
params.qtyAvailable ?? 0,
JSON.stringify(params.priceTiers ?? []),
params.hpSku ?? null,
params.hpItemId ?? null,
params.internalNotes ?? null,
params.handlingInstructions ?? null,
params.storageWarning ?? null,
params.productLabel ?? null,
params.packStyle ?? null,
params.containerType ?? null,
params.defaultPickupLocation ?? null,
]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, id: data?.id };
} catch {
return { success: false, error: "Failed to save product" };
}
}
// ── Settings ─────────────────────────────────────────────────────────────────
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
const bid = await getActiveBrandId(adminUser, brandId);
if (!bid && adminUser.role !== "platform_admin") {
return null;
}
try {
const { rows } = await pool.query<WholesaleSettings>(
"SELECT * FROM get_wholesale_settings($1)",
[bid]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function getWholesaleSettingsPublic(brandId: string): Promise<{ invoice_business_address: string | null } | null> {
try {
const { rows } = await pool.query<{ invoice_business_address: string | null }>(
"SELECT invoice_business_address FROM get_wholesale_settings($1)",
[brandId]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function saveWholesaleSettings(params: {
brandId: string;
requireApproval?: boolean;
minOrderAmount?: number;
onlinePaymentEnabled?: boolean;
wholesaleEnabled?: boolean;
squareSyncEnabled?: boolean;
pickupLocation?: string;
fobLocation?: string;
fromEmail?: string;
invoiceBusinessName?: string;
invoiceBusinessAddress?: string;
invoiceBusinessPhone?: string;
invoiceBusinessEmail?: string;
invoiceBusinessWebsite?: string;
notificationEmail?: string;
notificationRecipients?: NotificationRecipient[];
}): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
await pool.query(
"SELECT upsert_wholesale_settings($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)",
[
params.brandId,
params.requireApproval ?? null,
params.minOrderAmount ?? null,
params.onlinePaymentEnabled ?? null,
params.wholesaleEnabled ?? null,
params.pickupLocation ?? null,
params.fobLocation ?? null,
params.fromEmail ?? null,
params.invoiceBusinessName ?? null,
params.invoiceBusinessAddress ?? null,
params.invoiceBusinessPhone ?? null,
params.invoiceBusinessEmail ?? null,
params.invoiceBusinessWebsite ?? null,
params.notificationEmail ?? null,
params.notificationRecipients ? JSON.stringify(params.notificationRecipients) : null,
params.squareSyncEnabled ?? null,
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to save settings" };
}
}
// ── Deposits ─────────────────────────────────────────────────────────────────
export async function recordWholesaleDeposit(
orderId: string,
amount: number,
method: string = "cash",
reference?: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const activeBrandId = await getActiveBrandId(adminUser);
let data: { success?: boolean; error?: string } | null = null;
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
[orderId, amount, method, reference ?? null, adminUser.user_id, activeBrandId]
);
data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to record deposit" };
}
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to record deposit" };
}
// Fire webhook — fire-and-forget
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, activeBrandId ?? undefined).catch(() => {});
return { success: true };
}
async function enqueueWholesaleWebhookForDepositRecorded(orderId: string, amount: number, brandId?: string) {
try {
await pool.query(
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
[
"deposit_recorded",
orderId,
brandId ?? null,
JSON.stringify({ order_id: orderId, amount }),
]
);
} catch (_) {}
}
// ── Bulk Actions ──────────────────────────────────────────────────────────────
export async function bulkFulfillWholesaleOrders(
orderIds: string[]
): Promise<{ success: boolean; count?: number; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
"SELECT * FROM bulk_fulfill_wholesale_orders($1, $2, $3)",
[orderIds, adminUser.user_id, await getActiveBrandId(adminUser)]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" };
}
return { success: true, count: data.count };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk fulfill orders" };
}
}
export async function bulkRecordWholesaleDeposit(
orderIds: string[],
amount: number,
method: string = "cash",
reference?: string
): Promise<{ success: boolean; count?: number; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
"SELECT * FROM bulk_record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
[orderIds, amount, method, reference ?? null, adminUser.user_id, await getActiveBrandId(adminUser)]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to bulk record deposits" };
}
return { success: true, count: data.count };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk record deposits" };
}
}
// ── Notifications ─────────────────────────────────────────────────────────────
export type WholesaleNotification = {
id: string;
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
email_to: string;
email_cc: string | null;
subject: string;
body_html: string | null;
body_text: string | null;
brand_id: string;
customer_id: string;
order_id: string | null;
status: string;
invoice_business_name: string | null;
invoice_business_email: string | null;
created_at: string;
};
export async function getWholesaleNotificationStats(
brandId: string
): Promise<{ pending: number; sent: number; failed: number; total: number }> {
try {
const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>(
"SELECT * FROM get_wholesale_notification_stats($1)",
[brandId]
);
return rows[0] ?? { pending: 0, sent: 0, failed: 0, total: 0 };
} catch {
return { pending: 0, sent: 0, failed: 0, total: 0 };
}
}
export async function getWholesalePendingNotifications(
brandId: string,
limit = 50
): Promise<WholesaleNotification[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
if (!adminUser.can_manage_orders) return [];
try {
const { rows } = await pool.query<WholesaleNotification>(
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
[brandId, limit]
);
return rows;
} catch {
return [];
}
}
export async function markWholesaleNotificationSent(
notificationId: string,
error?: string
): Promise<{ success: boolean }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false };
if (!adminUser.can_manage_orders) return { success: false };
try {
await pool.query(
"SELECT mark_wholesale_notification_sent($1, $2)",
[notificationId, error ?? null]
);
return { success: true };
} catch {
return { success: false };
}
}
export async function enqueueWholesaleNotification(params: {
brandId: string;
customerId: string;
orderId: string;
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
emailTo: string;
emailCc?: string;
subject: string;
bodyHtml?: string;
bodyText?: string;
}): Promise<{ success: boolean; error?: string }> {
try {
await pool.query(
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
params.brandId,
params.customerId,
params.orderId,
params.type,
params.emailTo,
params.emailCc ?? null,
params.subject,
params.bodyHtml ?? null,
params.bodyText ?? null,
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to enqueue notification" };
}
}
// ── Webhooks ─────────────────────────────────────────────────────────────────
export type WebhookSettings = {
id: string;
brand_id: string;
url: string;
secret: string;
enabled: boolean;
created_at: string;
updated_at: string;
};
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
try {
const { rows } = await pool.query<WebhookSettings>(
"SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1",
[brandId]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function saveWebhookSettings(params: {
brandId: string;
url?: string;
secret?: string;
enabled?: boolean;
}): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
await pool.query(
"SELECT upsert_wholesale_webhook_settings($1::jsonb)",
[
JSON.stringify({
brand_id: params.brandId,
url: params.url ?? "",
secret: params.secret ?? "",
enabled: params.enabled ?? false,
}),
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to save webhook settings" };
}
}
export async function enqueueWholesaleWebhook(
eventType: "order_created" | "order_fulfilled" | "deposit_recorded" | "order_paid",
orderId: string | null = null,
payload: Record<string, unknown> | null = null,
brandId?: string
): Promise<{ success: boolean; logId?: string }> {
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT enqueue_wholesale_webhook($1, $2, $3::jsonb, $4)",
[eventType, orderId, payload ? JSON.stringify(payload) : null, brandId ?? null]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, logId: data?.id };
} catch {
return { success: false };
}
}
export async function getRecentWebhookActivity(brandId: string, limit = 10): Promise<Array<{
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
}>> {
try {
const { rows } = await pool.query<{
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
}>(
"SELECT * FROM wholesale_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT $2",
[brandId, limit]
);
return rows;
} catch {
return [];
}
}
+95
View File
@@ -0,0 +1,95 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { enforceBrandScope, resolveBrandId } from "./scope";
import type { WholesaleCustomer } from "./types";
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleCustomer>(
"SELECT * FROM get_wholesale_customers($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function saveWholesaleCustomer(params: {
brandId: string;
userId?: string;
companyName?: string;
contactName?: string;
email?: string;
phone?: string;
accountStatus?: string;
creditLimit?: number;
depositsEnabled?: boolean;
depositThreshold?: number;
depositPercentage?: number;
orderEmail?: string;
invoiceEmail?: string;
adminNotes?: string;
}): Promise<{ success: boolean; error?: string; id?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT * FROM upsert_wholesale_customer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
[
params.brandId,
params.userId ?? null,
params.companyName ?? null,
params.contactName ?? null,
params.email ?? null,
params.phone ?? null,
params.accountStatus ?? "active",
params.creditLimit ?? 0,
params.depositsEnabled ?? false,
params.depositThreshold ?? null,
params.depositPercentage ?? null,
params.orderEmail ?? null,
params.invoiceEmail ?? null,
params.adminNotes ?? null,
]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, id: data?.id };
} catch {
return { success: false, error: "Failed to save customer" };
}
}
export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM delete_wholesale_customer($1)",
[customerId]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Delete failed" };
}
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete customer" };
}
}

Some files were not shown because too many files have changed in this diff Show More