16 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
253 changed files with 1980 additions and 32824 deletions
+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`)*
+45 -46
View File
@@ -2,7 +2,9 @@
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
**Last updated:** 2026-06 (admin_users schema fix + migration reliability + Google sign-in work)
**Last updated:** 2026-06-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)
@@ -48,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).
---
@@ -210,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).
---
@@ -234,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.
---
+36 -50
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.
For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button.
+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
+9 -12
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)
```
@@ -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 };
-1
View File
@@ -2,7 +2,6 @@
-- Stops: 269 | Locations: 40
-- Preferred: npx tsx scripts/import-tuxedo-stops.ts
-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql
-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql
BEGIN;
+1 -1
View File
@@ -180,7 +180,7 @@ data explicitly checks its gate.
cold-start paths; the active `getAdminUser().brand_id` always wins
on later requests.
- No Supabase REST, no service-role keys — all access is direct
through Drizzle over a single `pg` Pool.
through Drizzle over a single shared `pg` Pool (see `src/lib/db.ts`).
---
+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",
+12 -4
View File
@@ -22,10 +22,6 @@ const nextConfig: NextConfig = {
// Optimize images
images: {
remotePatterns: [
{
protocol: "https",
hostname: "*.supabase.co",
},
{
protocol: "https",
hostname: "images.unsplash.com",
@@ -124,6 +120,18 @@ const nextConfig: NextConfig = {
{ 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 },
];
},
-2
View File
@@ -34,8 +34,6 @@
"@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",
-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 {}
}
}
})();
-78
View File
@@ -1,78 +0,0 @@
/**
* Cleanup script: remove duplicate stops from the Tuxedo Corn 2026 tour.
* Keeps the row with the latest created_at per (brand_id, date, city, state, location, time).
* Run: DATABASE_URL="postgresql://..." npx tsx scripts/cleanup-duplicate-stops.ts
*/
import { Pool } from "pg";
const pool = new Pool({
connectionString:
process.env.DATABASE_ADMIN_URL ??
process.env.DATABASE_URL ??
"postgres://postgres:postgres@localhost:5432/route_commerce",
});
async function main() {
const client = await pool.connect();
try {
await client.query("BEGIN");
// Find duplicate groups (same brand_id, date, city, state, location, time)
const dupes = await client.query(`
SELECT brand_id::text, date, city, state, location, time, count(*) as cnt, min(created_at::text) as oldest, max(created_at::text) as newest
FROM stops
WHERE deleted_at IS NULL
GROUP BY brand_id, date, city, state, location, time
HAVING count(*) > 1
ORDER BY date, city
`);
console.log(`Found ${dupes.rowCount} duplicate groups`);
if (dupes.rowCount === 0) {
await client.query("COMMIT");
console.log("No duplicates found.");
return;
}
let totalDeleted = 0;
for (const row of dupes.rows) {
// Keep the row with the latest created_at, delete the rest
const del = await client.query(`
DELETE FROM stops
WHERE brand_id = $1
AND date = $2
AND city = $3
AND state = $4
AND location = $5
AND time IS NOT DISTINCT FROM $6
AND deleted_at IS NULL
AND created_at < (
SELECT max(created_at) FROM stops s2
WHERE s2.brand_id = stops.brand_id
AND s2.date = stops.date
AND s2.city = stops.city
AND s2.state = stops.state
AND s2.location = stops.location
AND (s2.time IS NOT DISTINCT FROM stops.time)
AND s2.deleted_at IS NULL
)
`, [row.brand_id, row.date, row.city, row.state, row.location, row.time]);
totalDeleted += del.rowCount ?? 0;
console.log(` Deleted ${del.rowCount} duplicate(s) for ${row.city}, ${row.state} on ${row.date}`);
}
await client.query("COMMIT");
console.log(`\nDone. Total duplicates removed: ${totalDeleted}`);
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
await pool.end();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
-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"
+1 -2
View File
@@ -305,8 +305,7 @@ async function main() {
let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`;
sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`;
sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`;
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n`;
sql += `-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
sql += `BEGIN;\n\n`;
// Locations first (master directory)
-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();
-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"
-343
View File
@@ -1,343 +0,0 @@
#!/usr/bin/env python3
"""
seed_tuxedo_tour.py
Parses Tuxedo_Corn_2026_Tour_Schedule-3.xlsx and seeds the `stops` table for
the Tuxedo brand via the admin_create_stops_batch RPC.
Skips:
- Title / subtitle / legend rows (rows 1-3)
- Week header rows (col A = "Wk N", col D-J empty)
- Cross-Dock / Monday OFF rows (col D contains "OFF" or "Cross-Dock")
Joins with the Stop Directory sheet to enrich each stop with:
- address, phone, contact
Usage:
python3 scripts/seed_tuxedo_tour.py --dry-run # show what would be inserted
python3 scripts/seed_tuxedo_tour.py # actually insert
Requires:
- supabase CLI linked to project wnzkhezyhnfzhkhiflrp
"""
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from openpyxl import load_workbook
TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"
YEAR = 2026
MONTH_MAP = {
"Jan": "01", "Feb": "02", "Mar": "03", "Apr": "04",
"May": "05", "Jun": "06", "Jul": "07", "Aug": "08",
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
}
DEFAULT_XLSX = "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx" # run from repo root; override with --xlsx /path/to/file.xlsx
def parse_excel_date(s):
"""'Jul 22' -> '2026-07-22'"""
if not s:
return None
m = re.match(r"^([A-Za-z]{3})\s+(\d{1,2})$", str(s).strip())
if not m:
return None
mm = MONTH_MAP.get(m.group(1))
if not mm:
return None
return f"{YEAR}-{mm}-{int(m.group(2)):02d}"
def parse_time_range(s):
"""'10:00 AM - 1:00 PM' -> '10:00 AM' (start time)"""
if not s:
return ""
cleaned = re.sub(r"[–—]", "-", str(s)).strip()
cleaned = re.sub(r"\s+", " ", cleaned)
m = re.match(r"^(\d{1,2}:\d{2}\s*[AP]M)", cleaned, re.IGNORECASE)
return m.group(1).upper().replace(" ", " ") if m else cleaned
def split_city_state(s):
"""'Cheyenne, WY' -> ('Cheyenne', 'WY')"""
if not s:
return "", ""
parts = [p.strip() for p in str(s).split(",")]
if len(parts) == 1:
return parts[0], ""
return parts[0], parts[1]
def slugify(s):
s = (s or "").lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
def is_week_header(row):
a = str(row[0] or "").strip()
d = str(row[3] or "").strip()
return re.match(r"^Wk\s", a) and d == ""
def is_off_row(row):
d = str(row[3] or "").strip()
return "OFF" in d or "Cross-Dock" in d or "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);
});
+1 -1
View File
@@ -273,7 +273,7 @@ export async function createAdminUser(
// admin endpoint requires the caller to have role='admin' in
// Neon Auth, which is not always true (provision-admin.ts does
// not set it). Falling back to sign-up is a safe, well-tested
// path — see scripts/seed-admin.ts.
// path.
const code = adminResult.error.code ?? "";
const isAuthRequired =
code === "FAILED_TO_CREATE_USER" ||
-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." };
}
}
+4 -3
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,
+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];
+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
+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;
+4 -4
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,
+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
@@ -76,4 +76,31 @@ 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;
-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" };
}
}
+85
View File
@@ -0,0 +1,85 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { enqueueWholesaleWebhook } from "./webhooks";
export async function recordWholesaleDeposit(
orderId: string,
amount: number,
method: string = "cash",
reference?: 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 activeBrandId = await getActiveBrandId(adminUser, brandId);
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
enqueueWholesaleWebhook("deposit_recorded", orderId, { order_id: orderId, amount }, activeBrandId ?? undefined).catch(() => {});
return { success: true };
}
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" };
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Barrel re-export for the wholesale action set.
*
* Source modules live next to this file:
* - orders.ts — order CRUD + dashboard stats
* - customers.ts — wholesale customer CRUD
* - products.ts — wholesale product CRUD
* - settings.ts — wholesale_settings + public read
* - deposits.ts — deposit recording + bulk actions
* - notifications.ts — email/SMS notification queue
* - webhooks.ts — outbound webhook settings + dispatch
* - scope.ts — internal brand-scoping helpers (NOT re-exported here)
* - types.ts — shared type definitions
*
* Existing imports of `@/actions/wholesale` keep working. New code should
* import directly from the focused modules to keep the dependency graph
* tight.
*/
export type {
WholesaleOrder,
WholesaleCustomer,
WholesaleProduct,
NotificationRecipient,
WholesaleSettings,
WholesaleDashboardStats,
WholesaleNotification,
WebhookSettings,
} from "./types";
export {
getWholesaleOrders,
getWholesalePickupOrders,
getWholesaleDashboardStats,
markWholesaleOrderFulfilled,
updateWholesaleOrderStatus,
deleteWholesaleOrder,
} from "./orders";
export {
getWholesaleCustomers,
saveWholesaleCustomer,
deleteWholesaleCustomer,
} from "./customers";
export {
getWholesaleProducts,
saveWholesaleProduct,
deleteWholesaleProduct,
} from "./products";
export {
getWholesaleSettings,
getWholesaleSettingsPublic,
saveWholesaleSettings,
} from "./settings";
export {
recordWholesaleDeposit,
bulkFulfillWholesaleOrders,
bulkRecordWholesaleDeposit,
} from "./deposits";
export {
getWholesaleNotificationStats,
getWholesalePendingNotifications,
markWholesaleNotificationSent,
enqueueWholesaleNotification,
} from "./notifications";
export {
getWebhookSettings,
saveWebhookSettings,
enqueueWholesaleWebhook,
getRecentWebhookActivity,
} from "./webhooks";
+89
View File
@@ -0,0 +1,89 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import type { WholesaleNotification } from "./types";
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" };
}
}
+128
View File
@@ -0,0 +1,128 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { enforceBrandScope, resolveBrandId } from "./scope";
import { enqueueWholesaleWebhook } from "./webhooks";
import type { WholesaleDashboardStats, WholesaleOrder } from "./types";
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" };
}
enqueueWholesaleWebhook("order_fulfilled", orderId, { order_id: orderId }, resolved ?? undefined).catch(() => {});
return { success: true };
}
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" };
}
}
+101
View File
@@ -0,0 +1,101 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { enforceBrandScope, resolveBrandId } from "./scope";
import type { WholesaleProduct } from "./types";
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" };
}
}
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" };
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Brand scoping helpers shared across the wholesale actions.
*
* No "use server" so this can also be imported from non-action contexts
* (e.g. middleware, tests).
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
/**
* 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.
*/
export 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.
*/
export 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 };
}
+87
View File
@@ -0,0 +1,87 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import type { NotificationRecipient, WholesaleSettings } from "./types";
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" };
}
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Shared types for wholesale actions.
*
* Pure type definitions — no runtime imports, no "use server" so it can
* be imported into both server and client code.
*/
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;
};
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 type WebhookSettings = {
id: string;
brand_id: string;
url: string;
secret: string;
enabled: boolean;
created_at: string;
updated_at: string;
};
+91
View File
@@ -0,0 +1,91 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import type { WebhookSettings } from "./types";
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 [];
}
}
-103
View File
@@ -1,103 +0,0 @@
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { getAdminOrders } from "@/actions/orders";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase";
export const dynamic = "force-dynamic";
export default async function AdminOrdersPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_orders) {
redirect("/admin/pickup");
}
const activeBrandId = await getActiveBrandId(adminUser);
// Platform admin can browse all brands' orders; everyone else must have a brand
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied message="You don't have access to any brand." />;
}
const { orders, stops } = await getAdminOrders();
const brandStops = activeBrandId
? stops.filter((s) => s.brand_id === activeBrandId)
: stops;
const brandOrders = activeBrandId
? orders.filter(
(o) =>
o.stops && brandStops.some((s) => s.id === o.stop_id)
)
: orders;
// Fetch active products for this brand (for admin "New Order" item picker)
let brandProducts: Array<{ id: string; name: string; price: number; type?: string | null; active?: boolean }> = [];
try {
let prodQuery = supabase
.from("products")
.select("id, name, price, type, active")
.eq("active", true)
.is("deleted_at", null)
.order("name")
.limit(200);
if (activeBrandId) {
prodQuery = prodQuery.eq("brand_id", activeBrandId);
}
const { data: prods } = await prodQuery;
brandProducts = (prods ?? []).map((p) => ({
id: String(p.id),
name: String(p.name ?? ""),
price: Number(p.price),
type: p.type as string | null ?? null,
active: Boolean(p.active ?? true),
}));
} catch {
// non-fatal for the orders list
}
return (
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader
title="Orders"
subtitle="View, filter, and manage customer orders."
icon={
<svg className="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
<path d="M3 6h18"/>
<path d="M16 10a4 4 0 0 1-8 0"/>
</svg>
}
/>
</div>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<div
className="rounded-2xl border overflow-hidden"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<AdminOrdersPanel
initialOrders={brandOrders}
initialStops={brandStops}
initialProducts={brandProducts}
brandId={activeBrandId}
/>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export default async function AdminPage() {
// so a transient DB/network failure can't crash the whole admin page.
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
// Direct pg query (the supabase shim returns empty results).
// Direct pg query (the legacy query-builder shim returned empty results).
// This ensures a platform_admin sees real dashboard stats even on first login
// before they have chosen an active brand.
try {
-158
View File
@@ -1,158 +0,0 @@
import { supabase } from "@/lib/supabase";
import ProductEditForm from "@/components/admin/ProductEditForm";
import { getAdminUser } from "@/lib/admin-permissions";
import { PageHeader, AdminBadge } from "@/components/admin/design-system";
import { Package as PackageIcon } from "lucide-react";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { redirect } from "next/navigation";
import Link from "next/link";
type ProductDetailPageProps = {
params: Promise<{
id: string;
}>;
};
interface Product {
id: string;
brand_id: string;
name: string;
description: string | null;
price: number;
active: boolean;
type: string | null;
image_url: string | null;
is_taxable: boolean;
pickup_type: string | null;
brands?: { name: string; slug: string };
}
interface Brand {
id: string;
name: string;
slug: string;
}
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
const { id } = await params;
const [{ data: product, error }, { data: brands }] = await Promise.all([
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single() as unknown as { data: Product | null; error: { message: string } | null },
supabase.from("brands").select("id, name, slug") as unknown as { data: Brand[] | null },
]);
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_products) redirect("/admin/pickup");
if (adminUser?.brand_id && product?.brand_id !== adminUser.brand_id) {
return <AdminAccessDenied />;
}
if (error || !product) {
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-[var(--admin-danger)]">Product not found</h1>
<pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
{error?.message ?? "Product not found"}
</pre>
<Link
href="/admin/products"
className="mt-4 inline-block text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]"
>
Back to Products
</Link>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-4xl">
<div className="ha-eyebrow mb-2">Operations</div>
<PageHeader
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
title="Edit product"
subtitle="Update product details, pricing, and availability."
actions={
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
{product.active ? "Active" : "Inactive"}
</AdminBadge>
}
/>
<div className="mb-6">
<Link
href="/admin/products"
className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
Back to Products
</Link>
</div>
<div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
{product.brands?.name}
</p>
<h1 className="mt-2 text-3xl font-bold text-[var(--admin-text-primary)]">
{product.name}
</h1>
<p className="mt-2 text-lg text-[var(--admin-text-secondary)]">
{product.description}
</p>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-6">
<div>
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Price</p>
<p
className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
${Number(product.price).toFixed(2)}
</p>
</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Type</p>
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
{product.type}
</p>
</div>
</div>
</div>
<div className="mt-6 rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Product</h2>
<p className="mt-1 text-[var(--admin-text-muted)]">
Update product details, pricing, and availability.
</p>
<div className="mt-6">
<ProductEditForm
product={{
id: product.id,
name: product.name,
description: product.description ?? "",
price: Number(product.price),
type: product.type ?? "pickup",
active: product.active,
brand_id: product.brand_id,
image_url: product.image_url ?? "",
is_taxable: product.is_taxable,
pickup_type: product.pickup_type ?? "pickup",
}}
brands={brands ?? []}
/>
</div>
</div>
</div>
</main>
);
}
-42
View File
@@ -1,42 +0,0 @@
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import ReportsDashboard from "@/components/admin/ReportsDashboard";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation";
export default async function ReportsPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_reports) redirect("/admin/pickup");
const isPlatformAdmin = adminUser.role === "platform_admin";
const { data: brands } = await supabase
.from("brands")
.select("id, name")
.order("name") as unknown as { data: { id: string; name: string }[] | null };
const initialBrandId = isPlatformAdmin
? null
: await getActiveBrandId(adminUser);
return (
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-7xl">
<PageHeader
title="Reports"
subtitle="Operational visibility across orders, fulfillment, contacts, and campaigns."
/>
<ReportsDashboard
brands={brands ?? []}
initialBrandId={initialBrandId}
isPlatformAdmin={isPlatformAdmin}
brandId={adminUser.brand_id}
/>
</div>
</main>
);
}
-111
View File
@@ -1,111 +0,0 @@
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { getBillingOverview } from "@/actions/billing/billing-overview";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import BillingClientPage from "./BillingClientPage";
type Props = {
params: Promise<{ brandId?: string }>;
};
export default async function BillingPage({ params }: Props) {
const { brandId: brandIdParam } = await params;
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied message="You don't have access to that brand." />;
}
const effectiveBrandId = activeBrandId ?? "";
const isPlatformAdmin = adminUser.role === "platform_admin";
let resolvedBrandId = effectiveBrandId;
if (isPlatformAdmin && !resolvedBrandId) {
const { data: firstBrand } = await supabase
.from("brands")
.select("id")
.limit(1)
.single();
if (firstBrand?.id) {
resolvedBrandId = String(firstBrand.id);
} else {
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
<div className="mx-auto max-w-6xl">
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
<span>/</span>
<span className="text-[var(--admin-text-primary)]">Billing</span>
</nav>
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-8 text-center">
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)]">No Brands Found</h1>
<p className="mt-2 text-[var(--admin-text-muted)]">Create a brand in the database before accessing billing settings.</p>
<a href="/admin" className="mt-4 inline-block rounded-xl bg-[var(--admin-accent)] hover:bg-[var(--admin-accent-hover)] px-6 py-3 text-sm font-medium text-white transition-colors">
Back to Admin
</a>
</div>
</div>
</main>
);
}
}
if (!resolvedBrandId) return <AdminAccessDenied />;
// Single source of truth for everything the billing client needs.
const overviewRes = await getBillingOverview(resolvedBrandId);
if (!overviewRes.success || !overviewRes.data) {
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
<div className="mx-auto max-w-6xl">
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-8 text-center">
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)]">Billing Unavailable</h1>
<p className="mt-2 text-[var(--admin-text-muted)]">{overviewRes.error ?? "Could not load billing information."}</p>
<a href="/admin" className="mt-4 inline-block rounded-xl bg-[var(--admin-accent)] hover:bg-[var(--admin-accent-hover)] px-6 py-3 text-sm font-medium text-white transition-colors">
Back to Admin
</a>
</div>
</div>
</main>
);
}
const overview = overviewRes.data;
return (
<main className="min-h-screen bg-[var(--admin-bg)]">
{/* Platform billing header */}
<div className="bg-[var(--admin-bg-subtle)] border-b border-[var(--admin-border)] px-6 py-3">
<div className="mx-auto max-w-6xl">
<p className="text-xs text-[var(--admin-text-muted)]">
<span className="font-medium text-[var(--admin-text-primary)]">Route Commerce Platform Billing</span>
{" — "}Invoiced by Cielo Hermosa, LLC · Manage your platform subscription and add-ons.
{" "}Questions? <a href="mailto:billing@cielohermosa.com" className="text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] underline transition-colors">billing@cielohermosa.com</a>
</p>
</div>
</div>
<div className="mx-auto max-w-6xl px-6 py-10">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
<span>/</span>
<a href="/admin/settings" className="hover:text-[var(--admin-text-primary)] transition-colors">Settings</a>
<span>/</span>
<span className="text-[var(--admin-text-primary)]">Billing</span>
</nav>
<div className="mb-8">
<h1 className="text-3xl font-bold text-[var(--admin-text-primary)]">Billing &amp; Subscription</h1>
<p className="mt-1 text-[var(--admin-text-muted)]">
Manage your Route Commerce subscription for {overview.brandName ?? "your brand"}.
</p>
</div>
<BillingClientPage overview={overview} />
</div>
</main>
);
}
@@ -1,61 +0,0 @@
import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import IntegrationsClientPage from "./IntegrationsClientPage";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
export const metadata = {
title: "Integrations - Route Commerce Admin",
description: "Configure integrations for AI, email, SMS, and payments",
};
export default async function IntegrationsPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
const isPlatformAdmin = adminUser.role === "platform_admin";
const brandId = adminUser.brand_id ?? "";
// Platform admins: fetch all brands for the picker
const brands = isPlatformAdmin
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
: [];
return (
<main className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6 pb-10">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
<span>/</span>
<a href="/admin/settings" className="hover:text-[var(--admin-text-primary)] transition-colors">Settings</a>
<span>/</span>
<span className="text-[var(--admin-text-primary)]">Integrations</span>
</nav>
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)] text-white">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-text-primary)]">Integrations</h1>
<p className="text-sm text-[var(--admin-text-muted)]">
Connect AI, email, SMS, and payment providers to power your operations.
</p>
</div>
</div>
</div>
<IntegrationsClientPage
brandId={brandId}
brands={brands}
isPlatformAdmin={isPlatformAdmin}
/>
</div>
</main>
);
}
-63
View File
@@ -1,63 +0,0 @@
import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { getShippingSettings } from "@/actions/shipping/settings";
import ShippingSettingsForm from "@/components/admin/ShippingSettingsForm";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
export default async function ShippingSettingsPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_orders || adminUser.role === "store_employee") {
redirect("/admin/pickup");
}
const isPlatformAdmin = adminUser.role === "platform_admin";
const brandId = adminUser.brand_id ?? "";
// Platform admins: fetch all brands for the picker
const brands = isPlatformAdmin
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
: [];
const effectiveBrandId = brandId || (brands[0]?.id ?? "");
const result = await getShippingSettings(effectiveBrandId);
const settings = result.success ? result.settings : null;
return (
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-2xl">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs mb-6" style={{ color: "var(--admin-text-muted)" }}>
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
<span>/</span>
<span style={{ color: "var(--admin-text-primary)" }}>Shipping</span>
</nav>
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl" style={{ backgroundColor: "var(--admin-accent)", color: "white" }}>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25m-2.25 0h2.25m0 0v-.375c0-.621-.504-1.125-1.125-1.125H15m-1.5-3l1.5 0l.75 0v-.375c0-.621-.504-1.125-1.125-1.125H15m0 0v-.375c0-.621-.504-1.125-1.125-1.125H12" />
</svg>
</div>
<h1 className="text-3xl font-bold" style={{ color: "var(--admin-text-primary)" }}>Shipping Settings</h1>
</div>
<p className="mt-2 text-sm" style={{ color: "var(--admin-text-muted)" }}>
Configure FedEx integration for shipping fresh produce sweet corn, onions, and more.
</p>
</div>
<div className="rounded-2xl border p-6 shadow-lg" style={{ backgroundColor: "white", borderColor: "var(--admin-border)" }}>
<ShippingSettingsForm
settings={settings}
brandId={effectiveBrandId}
brands={brands}
isPlatformAdmin={isPlatformAdmin}
/>
</div>
</div>
</main>
);
}
-85
View File
@@ -1,85 +0,0 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { supabase } from "@/lib/supabase";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import TaxDashboard from "@/components/admin/TaxDashboard";
import { PageHeader } from "@/components/admin/design-system";
type Props = {
params: Promise<{ brandId?: string }>;
};
export default async function TaxesPage({ params }: Props) {
const { brandId: brandIdParam } = await params;
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied message="You don't have access to that brand." />;
}
const effectiveBrandId = activeBrandId ?? "";
const isPlatformAdmin = adminUser.role === "platform_admin";
let resolvedBrandId = effectiveBrandId;
if (isPlatformAdmin && !resolvedBrandId) {
const { data: firstBrand } = await supabase
.from("brands")
.select("id")
.limit(1)
.single() as unknown as { data: { id: string } | null };
if (firstBrand?.id) {
resolvedBrandId = String(firstBrand.id);
} else {
return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-6xl">
<div className="rounded-2xl p-8 text-center border" style={{
backgroundColor: "white",
borderColor: "var(--admin-border)"
}}>
<h1 className="text-2xl font-bold" style={{ color: "var(--admin-text-primary)" }}>No Brands Found</h1>
<p className="mt-2" style={{ color: "var(--admin-text-muted)" }}>Create a brand in the database first.</p>
<a href="/admin" className="mt-4 inline-block rounded-xl px-6 py-3 text-sm font-medium border transition-colors"
style={{
backgroundColor: "var(--admin-bg-subtle)",
borderColor: "var(--admin-border)",
color: "var(--admin-text-primary)"
}}>
Back to Admin
</a>
</div>
</div>
</main>
);
}
}
if (!resolvedBrandId) return <AdminAccessDenied />;
const { data: brands } = await supabase
.from("brands")
.select("id, name")
.order("name");
const allBrands = (brands ?? []) as Array<{ id: string; name: string }>;
return (
<main className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-6xl px-6 py-10">
<PageHeader
title="Tax Dashboard"
subtitle="Sales tax collected on orders shipped to nexus states."
/>
<TaxDashboard
brands={allBrands}
initialBrandId={resolvedBrandId}
isPlatformAdmin={isPlatformAdmin}
/>
</div>
</main>
);
}
@@ -28,7 +28,6 @@ import {
enqueueWholesaleNotification,
getWebhookSettings,
saveWebhookSettings,
enqueueWholesaleWebhook,
getRecentWebhookActivity,
} from "@/actions/wholesale";
import DepositModal from "@/components/wholesale/DepositModal";
@@ -1,36 +1,44 @@
import { NextResponse } from "next/server";
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
import { supabase } from "@/lib/supabase";
import { pool } from "@/lib/db";
export async function GET() {
const brandSlug = "indian-river-direct";
const { data: brand } = await supabase
.from("brands")
.select("id, name")
.eq("slug", brandSlug)
.single() as unknown as { data: { id: string; name: string } | null };
const { rows: brandRows } = await pool.query<{ id: string; name: string }>(
`SELECT id, name FROM brands WHERE slug = $1 LIMIT 1`,
[brandSlug],
);
const brand = brandRows[0];
if (!brand?.id) {
return new NextResponse("Brand not found", { status: 404 });
}
const { data: stops } = await supabase
.from("stops")
.select("*")
.eq("brand_id", brand.id)
.eq("active", true)
.order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null };
const { rows: stops } = await pool.query<{ city: string; state: string; date: string; time: string; location: string }>(
`SELECT city, state, date::text AS date, time, location
FROM stops
WHERE brand_id = $1 AND is_public = true AND status = 'active'
ORDER BY date ASC, time ASC`,
[brand.id],
);
const { data: settings } = await supabase
.from("brand_settings")
.select("logo_url, email, phone, schedule_pdf_notes")
.eq("brand_id", brand.id)
.single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null };
const { rows: settingsRows } = await pool.query<{
logo_url: string | null;
email: string | null;
phone: string | null;
schedule_pdf_notes: string | null;
}>(
`SELECT logo_url, email, phone, schedule_pdf_notes
FROM brand_settings
WHERE brand_id = $1
LIMIT 1`,
[brand.id],
);
const settings = settingsRows[0] ?? null;
const pdfBytes = await buildProfessionalSchedulePdf({
brandName: brand.name,
stops: stops ?? [],
stops: stops,
logoUrl: settings?.logo_url ?? null,
contactEmail: settings?.email ?? null,
contactPhone: settings?.phone ?? null,
+118
View File
@@ -0,0 +1,118 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
function escapeAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export async function POST(request: Request) {
try {
const form = await request.formData();
const code = String(form.get("code") ?? "");
const state = String(form.get("state") ?? "");
const error = String(form.get("error") ?? "");
const originHeader = request.headers.get("origin");
const baseUrl = originHeader ?? new URL(request.url).origin;
if (error) {
return NextResponse.redirect(
new URL(
`/admin/settings/payments?error=square_oauth_denied&reason=${encodeURIComponent(error)}`,
baseUrl,
),
);
}
if (!code || !state) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_oauth_missing_params", baseUrl),
);
}
let brandId: string | null = null;
try {
const decoded = JSON.parse(Buffer.from(state, "base64").toString("utf-8"));
brandId = decoded?.brandId ?? null;
} catch {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_oauth_invalid_state", baseUrl),
);
}
if (!brandId) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_oauth_missing_state", baseUrl),
);
}
const appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID;
const appSecret = process.env.SQUARE_APP_SECRET;
const env = process.env.SQUARE_ENVIRONMENT ?? "sandbox";
if (!appId || !appSecret) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_credentials_not_configured", baseUrl),
);
}
const tokenUrl =
env === "production"
? "https://connect.squareup.com/v2/oauth2/token"
: "https://connect.squareupsandbox.com/v2/oauth2/token";
const redirectUri = `${baseUrl}/api/square/oauth/callback`;
const tokenResponse = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Square-Version": "2025-01-16",
},
body: JSON.stringify({
client_id: appId,
client_secret: appSecret,
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}),
});
const tokenData = await tokenResponse.json();
const accessToken: string | null = tokenData?.access_token ?? null;
const locationId: string | null = tokenData?.location_id ?? null;
if (!tokenResponse.ok || !accessToken) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_token_exchange_failed", baseUrl),
);
}
await pool.query(
"SELECT upsert_payment_settings($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
brandId,
"square",
null,
null,
null,
accessToken,
locationId,
null,
null,
],
);
return NextResponse.redirect(
new URL("/admin/settings/payments?square_connected=true", baseUrl),
);
} catch (err) {
const baseUrl = request.headers.get("origin") ?? new URL(request.url).origin;
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_token_save_error", baseUrl),
);
}
}
export async function GET() {
// GET on /complete is not supported; redirect to settings.
return NextResponse.redirect(new URL("/admin/settings/payments"));
}
+102
View File
@@ -0,0 +1,102 @@
import { NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { savePaymentSettings } from "@/actions/payments";
export async function POST(request: Request) {
try {
const form = await request.formData();
const code = String(form.get("code") ?? "");
const error = String(form.get("error") ?? "");
const state = String(form.get("state") ?? "");
const baseUrl = request.headers.get("origin") ?? new URL(request.url).origin;
if (error) {
return NextResponse.redirect(
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(error)}`, baseUrl),
);
}
if (!code) {
return NextResponse.redirect(
new URL("/admin/settings/payments?stripe_error=no_code", baseUrl),
);
}
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.brand_id) {
return NextResponse.redirect(new URL("/admin/settings/payments?error=unauthorized", baseUrl));
}
let brandId = adminUser.brand_id;
if (state) {
try {
const decoded = JSON.parse(Buffer.from(state, "base64").toString());
if (decoded.brandId) brandId = decoded.brandId;
} catch {
// ignore malformed state
}
}
const clientId = process.env.STRIPE_CLIENT_ID;
const clientSecret = process.env.STRIPE_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return NextResponse.redirect(
new URL("/admin/settings/payments?stripe_error=oauth_not_configured", baseUrl),
);
}
const tokenResponse = await fetch("https://connect.stripe.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: clientId,
client_secret: clientSecret,
}),
});
const tokenData = await tokenResponse.json();
if (tokenData?.error) {
return NextResponse.redirect(
new URL(
`/admin/settings/payments?stripe_error=${encodeURIComponent(tokenData.error_description || tokenData.error)}`,
baseUrl,
),
);
}
const stripeUserId: string | undefined = tokenData.stripe_user_id;
const accessToken: string | undefined = tokenData.access_token;
const publishableKey: string | undefined = tokenData.stripe_publishable_key;
const result = await savePaymentSettings({
brandId,
provider: "stripe",
stripePublishableKey: publishableKey,
stripeSecretKey: accessToken,
stripeUserId,
});
if (result.success) {
return NextResponse.redirect(
new URL("/admin/settings/payments?stripe_connected=true", baseUrl),
);
}
return NextResponse.redirect(
new URL(
`/admin/settings/payments?stripe_error=${encodeURIComponent(result.error || "Failed to save")}`,
baseUrl,
),
);
} catch (err) {
const baseUrl = request.headers.get("origin") ?? new URL(request.url).origin;
return NextResponse.redirect(
new URL(
`/admin/settings/payments?stripe_error=${encodeURIComponent("Failed to complete OAuth")}`,
baseUrl,
),
);
}
}
export async function GET() {
return NextResponse.redirect(new URL("/admin/settings/payments"));
}
+27 -27
View File
@@ -1,44 +1,44 @@
import { NextResponse } from "next/server";
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
import { supabase } from "@/lib/supabase";
type Settings = {
logo_url: string | null;
contact_email: string | null;
contact_phone: string | null;
schedule_pdf_notes: string | null;
brand_name: string | null;
};
import { pool } from "@/lib/db";
export async function GET() {
const brandSlug = "tuxedo";
const { data: brand } = await supabase
.from("brands")
.select("id, name")
.eq("slug", brandSlug)
.single() as unknown as { data: { id: string; name: string } | null };
const { rows: brandRows } = await pool.query<{ id: string; name: string }>(
`SELECT id, name FROM brands WHERE slug = $1 LIMIT 1`,
[brandSlug],
);
const brand = brandRows[0];
if (!brand?.id) {
return new NextResponse("Brand not found", { status: 404 });
}
const { data: stops } = await supabase
.from("stops")
.select("city, state, date, time, location")
.eq("brand_id", brand.id)
.eq("active", true)
.order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null };
const { rows: stops } = await pool.query<{ city: string; state: string; date: string; time: string; location: string }>(
`SELECT city, state, date::text AS date, time, location
FROM stops
WHERE brand_id = $1 AND is_public = true AND status = 'active'
ORDER BY date ASC, time ASC`,
[brand.id],
);
const { data: settings } = await supabase
.from("brand_settings")
.select("logo_url, email, phone, schedule_pdf_notes")
.eq("brand_id", brand.id)
.single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null };
const { rows: settingsRows } = await pool.query<{
logo_url: string | null;
email: string | null;
phone: string | null;
schedule_pdf_notes: string | null;
}>(
`SELECT logo_url, email, phone, schedule_pdf_notes
FROM brand_settings
WHERE brand_id = $1
LIMIT 1`,
[brand.id],
);
const settings = settingsRows[0] ?? null;
const pdfBytes = await buildProfessionalSchedulePdf({
brandName: brand.name,
stops: stops ?? [],
stops: stops,
logoUrl: settings?.logo_url ?? null,
contactEmail: settings?.email ?? null,
contactPhone: settings?.phone ?? null,
+2 -1
View File
@@ -4,7 +4,8 @@
* TODO(migration): wholesale_orders is part of the legacy schema and
* is read/written via raw `pool.query` SQL. The `get_wholesale_settings`
* and `get_payment_settings` SECURITY DEFINER RPCs still live in the
* database (see supabase/migrations/046 and 045) and are also called
* database (originally from the now-archived supabase migrations;
* consolidated into `db/migrations/0001_init.sql`) and are also called
* via `pool.query`. When wholesale is reactivated, declare the tables
* in `db/schema/wholesale.ts` and switch the reads to typed Drizzle.
*/
+4 -4
View File
@@ -5,10 +5,10 @@
* `enqueue_wholesale_notification` SECURITY DEFINER RPC live in the
* legacy schema. Reads are converted to `pool.query`; the
* `enqueue_wholesale_notification` RPC is still in the database
* (supabase/migrations/054) and is called via `pool.query` rather
* than the Supabase REST gateway. When wholesale is reactivated,
* move the tables into `db/schema/wholesale.ts` and switch reads to
* typed Drizzle.
* (originally from the now-archived supabase migrations; consolidated
* into `db/migrations/0001_init.sql`) and is called via `pool.query`.
* When wholesale is reactivated, move the tables into
* `db/schema/wholesale.ts` and switch reads to typed Drizzle.
*/
import { NextRequest, NextResponse } from "next/server";
@@ -5,14 +5,11 @@ import { motion } from "framer-motion";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
import { supabase } from "@/lib/supabase";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
import { getStorefrontWholesaleSettings } from "@/actions/storefront";
type BrandSettings = {
invoice_business_name: string | null;
invoice_business_address: string | null;
invoice_business_phone: string | null;
invoice_business_email: string | null;
invoice_business_website: string | null;
};
type FormState = {
@@ -34,17 +31,22 @@ export default function IndianRiverContactPage() {
const [contactPhone, setContactPhone] = useState<string | null>(null);
useEffect(() => {
supabase.from("brands").select("id").eq("slug", "indian-river-direct").single().then(({ data: brand }) => {
if (!brand?.id) return;
supabase.from("wholesale_settings")
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website")
.eq("brand_id", brand.id).single().then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null));
supabase.from("brand_settings").select("logo_url, custom_footer_text, email, phone")
.eq("brand_id", brand.id).single().then(({ data: s }) => {
if (s) { setLogoUrl((s as { logo_url: string | null }).logo_url ?? null); setCustomFooterText((s as { custom_footer_text: string | null }).custom_footer_text ?? null); setContactEmail((s as { email: string | null }).email ?? null); setContactPhone((s as { phone: string | null }).phone ?? null); }
});
try { import("@/actions/admin-user").then(({ getCurrentAdminUser }) => { getCurrentAdminUser().then((u: unknown) => setIsAdmin(!!u)); }); } catch { /* not logged in */ }
Promise.all([
getStorefrontWholesaleSettings("indian-river-direct"),
getBrandSettingsPublic("indian-river-direct"),
]).then(([wholesale, brandSettings]) => {
setBrandSettings(wholesale as BrandSettings | null);
if (brandSettings.success && brandSettings.settings) {
const s = brandSettings.settings;
setLogoUrl(s.logo_url ?? null);
setCustomFooterText(s.custom_footer_text ?? null);
setContactEmail(s.email ?? null);
setContactPhone(s.phone ?? null);
}
});
import("@/actions/admin-user").then(({ getCurrentAdminUser }) => {
getCurrentAdminUser().then((u: unknown) => setIsAdmin(!!u));
}).catch(() => { /* not logged in */ });
}, []);
function handleSubmit(e: React.FormEvent) {
@@ -84,7 +86,7 @@ export default function IndianRiverContactPage() {
{[
{
label: "Address",
value: brandSettings?.invoice_business_address ?? "Indian River Region, Florida",
value: "Indian River Region, Florida",
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />,
iconPath: <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
},
@@ -96,7 +98,7 @@ export default function IndianRiverContactPage() {
},
{
label: "Email",
value: brandSettings?.invoice_business_email ?? "Info@indianriverdirect.com",
value: "Info@indianriverdirect.com",
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />,
iconPath: null
},
@@ -6,7 +6,7 @@ import { motion } from "framer-motion";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
import { supabase } from "@/lib/supabase";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
type FAQCategory = {
category: string;
@@ -136,10 +136,10 @@ export default function IndianRiverFAQPage() {
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
supabase.from("brand_settings").select("phone").eq("brand_id",
"b1cb7a96-d82b-40b1-80b1-d6dd26c56e28"
).single().then(({ data }) => {
if (data && (data as { phone: string | null }).phone) setFaqCategories(buildFaqCategories((data as { phone: string | null }).phone!));
getBrandSettingsPublic("indian-river-direct").then((result) => {
if (result.success && result.settings?.phone) {
setFaqCategories(buildFaqCategories(result.settings.phone));
}
});
}, []);
+6 -10
View File
@@ -8,8 +8,8 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
import WhyIndianRiverDirect from "@/components/storefront/WhyIndianRiverDirect";
import { supabase } from "@/lib/supabase";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
import { getStorefrontData } from "@/actions/storefront";
type Brand = { id: string; name: string; slug: string };
type Stop = { id: string; city: string; state: string; date: string; time: string; location: string; slug: string; brand_id: string };
@@ -85,12 +85,12 @@ export default function IndianRiverDirectPage() {
useEffect(() => {
async function load() {
const slug = "indian-river-direct";
const [brandResult, settingsResult] = await Promise.all([
supabase.from("brands").select("*").eq("slug", slug).single(),
const [storefront, settingsResult] = await Promise.all([
getStorefrontData(slug),
getBrandSettingsPublic(slug),
]);
const brandData = brandResult.data as Brand | null;
const brandData = storefront.brand as Brand | null;
setBrand(brandData);
if (settingsResult.success && settingsResult.settings) {
@@ -110,12 +110,8 @@ export default function IndianRiverDirectPage() {
} catch { /* not logged in */ }
if (brandData?.id) {
const [{ data: stopsData }, { data: productsData }] = await Promise.all([
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null },
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null },
]);
setStops(stopsData ?? []);
setProducts(productsData ?? []);
setStops(storefront.stops as unknown as Stop[]);
setProducts(storefront.products as unknown as Product[]);
}
}
load();
@@ -69,7 +69,7 @@ export default function IndianRiverStopsList({ stops, brandName, brandSlug }: Pr
{upcomingStops.map((stop) => (
<Link
key={stop.id}
href={`/${brandSlug}/stops/${stop.slug}`}
href={`/${brandSlug}/stops/${stop.id}`}
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-orange-200"
>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
@@ -9,7 +9,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
import { supabase } from "@/lib/supabase";
import { getStorefrontStopById } from "@/actions/storefront";
import { formatDate } from "@/lib/format-date";
type Product = {
@@ -36,7 +36,7 @@ type Stop = {
export default function StopPage() {
const params = useParams();
const slug = params.slug as string;
const slug = params.id as string;
const [stop, setStop] = useState<{
id: string;
@@ -53,26 +53,11 @@ export default function StopPage() {
useEffect(() => {
async function load() {
const { data: stopData } = await supabase
.from("stops")
.select("*")
.eq("slug", slug)
.eq("active", true)
.single() as unknown as { data: Stop | null };
const result = await getStorefrontStopById(slug);
if (!result) return;
if (!stopData) return;
setStop(stopData);
const isIndian = slug.includes("indian");
setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo");
setBrandAccent(isIndian ? "blue" : "green");
const { data: productsData } = await supabase
.from("products")
.select("*")
.eq("brand_id", stopData.brand_id)
.eq("active", true) as unknown as { data: Product[] | null };
setProducts(productsData ?? []);
setStop(result.stop as unknown as Stop);
setProducts(result.products as unknown as Product[]);
}
load();
}, [slug]);
-21
View File
@@ -1,21 +0,0 @@
import { supabase } from "@/lib/supabase";
export const dynamic = "force-dynamic";
export default async function TestPage() {
const { data, error } = await supabase
.from("brands")
.select("*");
return (
<main className="min-h-screen bg-slate-50 p-10">
<h1 className="text-3xl font-bold">
Supabase Test
</h1>
<pre className="mt-8 rounded-xl bg-black p-6 text-sm text-green-400">
{JSON.stringify({ data, error }, null, 2)}
</pre>
</main>
);
}
+6 -15
View File
@@ -5,14 +5,10 @@ import Link from "next/link";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
import { supabase } from "@/lib/supabase";
import { getStorefrontWholesaleSettings } from "@/actions/storefront";
type BrandSettings = {
invoice_business_name: string | null;
invoice_business_address: string | null;
invoice_business_phone: string | null;
invoice_business_email: string | null;
invoice_business_website: string | null;
};
type FormState = {
@@ -29,13 +25,8 @@ export default function TuxedoContactPage() {
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
supabase
.from("wholesale_settings")
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website")
.eq("brand_id", TUXEDO_BRAND_ID)
.single()
.then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null));
getStorefrontWholesaleSettings("tuxedo")
.then((data) => setBrandSettings(data as BrandSettings | null));
}, []);
function handleSubmit(e: React.FormEvent) {
@@ -77,7 +68,7 @@ export default function TuxedoContactPage() {
</div>
<h3 className="text-base font-bold text-stone-950 mb-3">Farm Address</h3>
<p className="text-sm text-stone-500 leading-relaxed">
{brandSettings?.invoice_business_address ?? "59751 David Road, Olathe, CO 81425"}
59751 David Road, Olathe, CO 81425
</p>
</div>
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center">
@@ -101,8 +92,8 @@ export default function TuxedoContactPage() {
</svg>
</div>
<h3 className="text-base font-bold text-stone-950 mb-3">Email</h3>
<a href={`mailto:${brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"}`} className="text-sm text-stone-500 hover:text-emerald-700 transition-colors">
{brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"}
<a href="mailto:orders@tuxedocorn.com" className="text-sm text-stone-500 hover:text-emerald-700 transition-colors">
orders@tuxedocorn.com
</a>
</div>
</div>
+6 -10
View File
@@ -12,8 +12,8 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import BrandStylesProvider from "@/components/storefront/BrandStylesProvider";
import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations";
import { supabase } from "@/lib/supabase";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
import { getStorefrontData } from "@/actions/storefront";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
@@ -448,12 +448,12 @@ export default function TuxedoPage() {
async function load() {
const slug = "tuxedo";
const [brandResult, settingsResult] = await Promise.all([
supabase.from("brands").select("*").eq("slug", slug).single(),
const [storefront, settingsResult] = await Promise.all([
getStorefrontData(slug),
getBrandSettingsPublic(slug),
]);
const brandData = brandResult.data as Brand | null;
const brandData = storefront.brand as Brand | null;
setBrand(brandData);
if (settingsResult.success && settingsResult.settings) {
@@ -484,12 +484,8 @@ export default function TuxedoPage() {
}
if (brandData?.id) {
const [{ data: stopsData }, { data: productsData }] = await Promise.all([
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null },
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null },
]);
setStops(stopsData ?? []);
setProducts(productsData ?? []);
setStops(storefront.stops as unknown as Stop[]);
setProducts(storefront.products as unknown as Product[]);
}
}
load();
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { Fraunces, JetBrains_Mono } from "next/font/google";
import { supabase } from "@/lib/supabase";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
import { getStorefrontBrandBySlug } from "@/actions/storefront";
import SweetCornProductPage from "@/components/storefront/SweetCornProductPage";
const fraunces = Fraunces({
@@ -76,14 +76,13 @@ export default async function SweetCornBoxPage() {
let logoUrlDark: string | null = null;
try {
const [brandRes, settingsRes] = await Promise.all([
supabase.from("brands").select("id, name").eq("slug", BRAND_SLUG).single(),
const [brand, settingsRes] = await Promise.all([
getStorefrontBrandBySlug(BRAND_SLUG),
getBrandSettingsPublic(BRAND_SLUG),
]);
if (brandRes.data) {
const b = brandRes.data as Brand;
brandId = b.id;
brandName = b.name;
if (brand) {
brandId = brand.id;
brandName = brand.name;
}
if (settingsRes.success && settingsRes.settings) {
const s = settingsRes.settings as Settings;
+1 -1
View File
@@ -86,7 +86,7 @@ export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props)
{upcomingStops.map((stop) => (
<Link
key={stop.id}
href={`/${brandSlug}/stops/${stop.slug}`}
href={`/${brandSlug}/stops/${stop.id}`}
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-emerald-200"
>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
@@ -8,7 +8,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
import { supabase } from "@/lib/supabase";
import { getStorefrontStopById } from "@/actions/storefront";
import { formatDate } from "@/lib/format-date";
type Product = {
@@ -35,7 +35,7 @@ type Stop = {
export default function StopPage() {
const params = useParams();
const slug = params.slug as string;
const slug = params.id as string;
const [stop, setStop] = useState<{
id: string;
@@ -52,26 +52,11 @@ export default function StopPage() {
useEffect(() => {
async function load() {
const { data: stopData } = await supabase
.from("stops")
.select("*")
.eq("slug", slug)
.eq("active", true)
.single() as unknown as { data: Stop | null };
const result = await getStorefrontStopById(slug);
if (!result) return;
if (!stopData) return;
setStop(stopData);
const isIndian = slug.includes("indian");
setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo");
setBrandAccent(isIndian ? "blue" : "green");
const { data: productsData } = await supabase
.from("products")
.select("*")
.eq("brand_id", stopData.brand_id)
.eq("active", true) as unknown as { data: Product[] | null };
setProducts(productsData ?? []);
setStop(result.stop as unknown as Stop);
setProducts(result.products as unknown as Product[]);
}
load();
}, [slug]);
+5 -19
View File
@@ -5,6 +5,7 @@ import { formatDate } from "@/lib/format-date";
import { useRouter, useSearchParams } from "next/navigation";
import { getWholesaleCustomerByUser, submitWholesaleOrder, getWholesaleProducts, getWholesaleCustomerOrders, getWholesaleCustomerPricing, getWholesaleCustomer, type WholesaleProduct, type WholesaleCustomerOrder, type WholesalePricingOverride } from "@/actions/wholesale-register";
import { getWholesaleSettings, enqueueWholesaleNotification } from "@/actions/wholesale";
import { getWholesalePortalConfig, getStorefrontBrandName } from "@/actions/storefront";
type CartItem = {
product: WholesaleProduct;
@@ -281,12 +282,7 @@ export default function WholesalePortalPage() {
setLoading(false);
return;
}
const { supabase } = await import("@/lib/supabase");
const { data: ws } = await supabase
.from("wholesale_settings")
.select("wholesale_enabled, online_payment_enabled")
.eq("brand_id", cust.brand_id)
.single() as unknown as { data: { wholesale_enabled: boolean; online_payment_enabled: boolean } | null };
const ws = await getWholesalePortalConfig(cust.brand_id);
setOnlinePaymentEnabled(ws?.online_payment_enabled ?? false);
setCustomer(cust);
setPreviewMode(true);
@@ -327,12 +323,7 @@ export default function WholesalePortalPage() {
return;
}
const { supabase } = await import("@/lib/supabase");
const { data: ws } = await supabase
.from("wholesale_settings")
.select("wholesale_enabled, online_payment_enabled")
.eq("brand_id", cust.brand_id)
.single() as unknown as { data: { wholesale_enabled: boolean; online_payment_enabled: boolean } | null };
const ws = await getWholesalePortalConfig(cust.brand_id);
if (ws && ws.wholesale_enabled === false) {
router.push("/wholesale/login?error=portal_disabled");
return;
@@ -349,13 +340,8 @@ export default function WholesalePortalPage() {
}, [router]);
async function loadBrandName(brandId: string) {
const { supabase } = await import("@/lib/supabase");
const { data: brand } = await supabase
.from("brands")
.select("name")
.eq("id", brandId)
.single() as unknown as { data: { name: string } | null };
if (brand) setBrandName(brand.name);
const name = await getStorefrontBrandName(brandId);
if (name) setBrandName(name);
}
async function loadProducts(brandId: string) {
+2 -6
View File
@@ -48,12 +48,8 @@ export default function WholesaleRegisterPage() {
useEffect(() => {
async function checkEnabled(brandId: string) {
const { supabase } = await import("@/lib/supabase");
const { data: ws } = await supabase
.from("wholesale_settings")
.select("wholesale_enabled")
.eq("brand_id", brandId)
.single();
const { getWholesalePortalConfig } = await import("@/actions/storefront");
const ws = await getWholesalePortalConfig(brandId);
if (ws && ws.wholesale_enabled === false) {
setPortalDisabled(true);
}
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Edge-safe Neon Auth configuration.
*
* This file is imported by `src/middleware.ts`, which runs in the Edge
* This file is imported by `src/proxy.ts`, which runs in the Edge
* runtime. It must NOT import:
* - `pg` / any Node-only database driver
* - The Drizzle client
+2
View File
@@ -1,3 +1,5 @@
"use client";
import { ReactNode } from "react";
import AdminSidebar from "@/components/admin/AdminSidebar";
import MobileTabBar from "@/components/admin/MobileTabBar";
+3 -7
View File
@@ -24,13 +24,9 @@ export default function SquareSyncWidget({ brandId }: Props) {
const [queueCount, setQueueCount] = useState(0);
const checkQueueCount = useCallback(async () => {
const { supabase } = await import("@/lib/supabase");
const result = await supabase
.from("square_sync_queue")
.select("*", { count: "exact", head: true })
.eq("brand_id", brandId)
.in("status", ["pending"]) as unknown as { count: number | null };
setQueueCount(result.count ?? 0);
const { getSquareQueueCount } = await import("@/actions/square-sync-ui");
const count = await getSquareQueueCount(brandId);
setQueueCount(count);
}, [brandId]);
useEffect(() => {
+4 -4
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import { formatDate } from "@/lib/format-date";
type StopCardProps = {
slug: string;
id: string;
city: string;
state: string;
date: string;
@@ -13,7 +13,7 @@ type StopCardProps = {
};
export default function StopCard({
slug, city, state, date, time, location,
id, city, state, date, time, location,
brandSlug = "tuxedo", brandAccent = "green",
}: StopCardProps) {
const ctaBg =
@@ -25,7 +25,7 @@ export default function StopCard({
return (
<div className="group overflow-hidden rounded-3xl bg-white ring-1 ring-stone-200/60 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-black/8 hover:ring-stone-300">
<Link href={`/${brandSlug}/stops/${slug}`} className="block p-7">
<Link href={`/${brandSlug}/stops/${id}`} className="block p-7">
{/* Location */}
<div className="flex items-start gap-4 mb-5">
<div className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl transition-colors ${brandAccent === "blue" ? "bg-blue-50 group-hover:bg-blue-100" : brandAccent === "green" ? "bg-emerald-50 group-hover:bg-emerald-100" : "bg-orange-50 group-hover:bg-orange-100"}`}>
@@ -56,7 +56,7 @@ export default function StopCard({
{/* CTA */}
<div className="px-7 pb-7 pt-1">
<Link
href={`/${brandSlug}/stops/${slug}`}
href={`/${brandSlug}/stops/${id}`}
className={`block w-full rounded-2xl ${ctaBg} text-white px-5 py-3.5 text-center font-bold text-sm uppercase tracking-wider transition-colors`}
>
Shop This Stop
-76
View File
@@ -1,76 +0,0 @@
/**
* Service-layer admin user creation. Hits the `admin_users` table directly
* via the shared pg pool. Returns the inserted row (or existing row if the
* user was already provisioned).
*/
export async function createAdminUser(
userId: string,
role: string,
brandId: string | null
): Promise<Record<string, unknown> | null> {
const { pool } = await import("@/lib/db");
const body = {
user_id: userId,
role,
brand_id: brandId,
active: true,
can_manage_products: role === "platform_admin",
can_manage_stops: role === "platform_admin",
can_manage_orders: true,
can_manage_pickup: role !== "store_employee",
can_manage_messages: role === "platform_admin",
can_manage_refunds: role === "platform_admin",
can_manage_users: role === "platform_admin",
can_manage_water_log: role === "platform_admin",
can_manage_reports: role === "platform_admin",
can_manage_settings: role === "platform_admin",
must_change_password: false,
};
try {
const { rows } = await pool.query<Record<string, unknown>>(
`INSERT INTO admin_users
(user_id, role, brand_id, active,
can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports,
can_manage_settings, must_change_password)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT (user_id) DO UPDATE
SET role = EXCLUDED.role,
brand_id = EXCLUDED.brand_id,
active = EXCLUDED.active,
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,
can_manage_settings = EXCLUDED.can_manage_settings
RETURNING *`,
[
body.user_id,
body.role,
body.brand_id,
body.active,
body.can_manage_products,
body.can_manage_stops,
body.can_manage_orders,
body.can_manage_pickup,
body.can_manage_messages,
body.can_manage_refunds,
body.can_manage_users,
body.can_manage_water_log,
body.can_manage_reports,
body.can_manage_settings,
body.must_change_password,
],
);
return rows[0] ?? null;
} catch {
return null;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { getAdminUser } from "@/lib/admin-permissions";
import type { AdminUser } from "@/lib/admin-permissions-types";
export type AuthGuardResult =
| { authorized: true; adminUser: AdminUser }
| { authorized: false; adminUser: null };
/**
* Require an authenticated admin user. Returns a discriminated result so
* callers can branch without throwing. Use this in API route handlers that
* need a uniform "is this request authenticated?" check before proceeding.
*/
export async function requireAdminUser(): Promise<AuthGuardResult> {
const adminUser = await getAdminUser();
if (!adminUser) {
return { authorized: false, adminUser: null };
}
return { authorized: true, adminUser };
}
+3 -3
View File
@@ -11,9 +11,9 @@
* const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]);
*
* Configuration:
* - DATABASE_URL (required) — full Postgres connection string. Same env var
* is used by `supabase/push-migrations.js` and any external migration
* tooling. Format: `postgres://user:pass@host:port/dbname`.
* - DATABASE_URL (required) — full Postgres connection string. Used by
* `scripts/migrate.js` (and any external migration tooling). Format:
* `postgres://user:pass@host:port/dbname`.
*
* Notes:
* - This module is server-only. It must never be imported from a Client
+77
View File
@@ -0,0 +1,77 @@
// FedEx OAuth token cache.
// This module intentionally lives OUTSIDE any "use server" file. The
// `react-doctor/server-no-mutable-module-state` rule flags module-scope
// mutable state inside "use server" files because it can leak per-user
// state across requests. The FedEx token cache is NOT per-user state —
// it's a service-credential cache shared across all requests, which is
// the desired behavior. Co-locating the cache in a non-server file keeps
// the rule satisfied while preserving the cross-request token reuse that
// FedEx's rate-limited OAuth endpoint requires.
type FedExAuthToken = {
accessToken: string;
expiresAt: number;
};
const FEDEX_BASE_URL = "https://apis.fedex.com";
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
export function getFedExApiBaseUrl(useProduction: boolean): string {
return useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
}
// Buffer before expiry to refresh proactively
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
let cachedToken: FedExAuthToken | null = null;
export async function getFedExAuthToken(settings: {
fedexApiKey: string;
fedexApiSecret: string;
useProduction: boolean;
}): Promise<{ accessToken: string } | { error: string }> {
const base = settings.useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
if (cachedToken && Date.now() < cachedToken.expiresAt - EXPIRY_BUFFER_MS) {
return { accessToken: cachedToken.accessToken };
}
const credentials = Buffer.from(
`${settings.fedexApiKey}:${settings.fedexApiSecret}`
).toString("base64");
try {
const res = await fetch(`${base}/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${credentials}`,
},
body: "grant_type=client_credentials",
});
if (!res.ok) {
const text = await res.text();
return { error: `FedEx auth failed: ${text}` };
}
const data = (await res.json()) as {
access_token: string;
expires_in: number;
};
cachedToken = {
accessToken: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
return { accessToken: data.access_token };
} catch (err) {
return { error: `FedEx auth network error: ${(err as Error).message}` };
}
}
// Test-only helper to reset the cache between unit tests.
export function __resetFedExTokenCacheForTests(): void {
cachedToken = null;
}
-372
View File
@@ -1,372 +0,0 @@
/**
* Compatibility shim that preserves the historical Supabase client
* query-builder API surface (`.from().select()...`) for the legacy
* public-storefront and admin pages that still call into it. The SaaS
* rebuild no longer uses Supabase as a backend server actions and
* API routes connect directly to Postgres via `pg` / `withDb` from
* `@/db/client`. This shim exists so the build keeps passing while
* the remaining legacy call sites are migrated to Drizzle / raw `pg`
* queries.
*
* IMPORTANT: This shim does NOT talk to a real database. It returns
* empty result sets. Legacy call sites that need real data must be
* rewritten against `pool` / `withDb` / `withBrand`.
*
* The query-builder API surface supported here is intentionally narrow:
* - .from(table).select(cols?).eq(col, val).eq(...).is(col, null).
* order(col, opts?).limit(n).range(min, max).single() returns
* `{ data, error }` like the Supabase client did.
* - .from(table).insert(payload).select().single() for legacy inserts
* - .from(table).upsert(payload)
* - .from(table).update(payload).eq(col, val)
* - .from(table).delete().eq(col, val)
* - .rpc(fnName, params) returns `{ data, error }` (data is null)
* - .auth.{getSession, getUser, signInWithPassword, signOut,
* updateUser, onAuthStateChange} all return null / no-ops
* - .storage.from(bucket).{upload, download, remove, list}
* - .channel().on().subscribe()
*
* If a call site needs more than that, migrate the call site.
*/
type FilterOp = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "is" | "like" | "ilike" | "in";
type Filter = { column: string; value: unknown; op: FilterOp };
// Result types for the mock query builder
interface MockQueryResult<T> {
data: T[] | null;
error: null;
}
interface MockSingleResult<T> {
data: T | null;
error: null;
}
class MockQueryBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
private tableName: string;
private filters: Filter[] = [];
private selectColumns: string = "*";
private orderColumn?: string;
private orderDirection?: "asc" | "desc";
private limitValue?: number;
private rangeMin?: number;
private rangeMax?: number;
private mode: "select" | "update" | "delete" = "select";
private mutationData: Record<string, unknown> | null = null;
constructor(tableName: string) {
this.tableName = tableName;
// No mock data - return empty results
}
select(columns: string = "*", _opts?: Record<string, unknown>) {
this.selectColumns = columns;
return this;
}
eq(column: string, value: unknown) {
this.filters.push({ column, value, op: "eq" });
return this;
}
neq(column: string, value: unknown) {
this.filters.push({ column, value, op: "neq" });
return this;
}
gt(column: string, value: unknown) {
this.filters.push({ column, value, op: "gt" });
return this;
}
gte(column: string, value: unknown) {
this.filters.push({ column, value, op: "gte" });
return this;
}
lt(column: string, value: unknown) {
this.filters.push({ column, value, op: "lt" });
return this;
}
lte(column: string, value: unknown) {
this.filters.push({ column, value, op: "lte" });
return this;
}
is(column: string, value: unknown) {
this.filters.push({ column, value, op: "is" });
return this;
}
like(column: string, value: string) {
this.filters.push({ column, value, op: "like" });
return this;
}
ilike(column: string, value: string) {
this.filters.push({ column, value, op: "ilike" });
return this;
}
in(column: string, values: unknown[]) {
this.filters.push({ column, value: values, op: "in" });
return this;
}
order(column: string, options?: { ascending?: boolean; nullsFirst?: boolean }) {
this.orderColumn = column;
this.orderDirection = options?.ascending === false ? "desc" : "asc";
return this;
}
limit(count: number) {
this.limitValue = count;
return this;
}
range(min: number, max: number) {
this.rangeMin = min;
this.rangeMax = max;
return this;
}
// The legacy Supabase client returns a thenable from .single() so
// callers can write `.single().then(({ data, error }) => ...)` as
// well as `const { data, error } = await ....single()`. We return a
// proper Promise<MockSingleResult<T>> so destructured binding
// patterns in callers work under `--strict` (no implicit `any`).
single(): Promise<MockSingleResult<T>> {
return Promise.resolve(this.executeSingle());
}
maybeSingle(): Promise<MockSingleResult<T>> {
return Promise.resolve(this.executeSingle());
}
then<TResult1 = MockQueryResult<T>, TResult2 = never>(
onfulfilled?: ((value: MockQueryResult<T>) => TResult1 | PromiseLike<TResult1>) | null,
_onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): PromiseLike<TResult1 | TResult2> {
const result = this.execute();
return Promise.resolve(onfulfilled ? onfulfilled(result) : (result as unknown as TResult1));
}
// Insert / update / delete mutators — these are mostly used by legacy
// auth flows and the AI preferences action. We capture the data and
// short-circuit to a successful no-op response so the call sites
// don't blow up. Real writes must go through server actions.
executeSingle(): MockSingleResult<T> {
const result = this.execute();
if (Array.isArray(result.data) && result.data.length > 0) {
return { data: result.data[0] as T, error: null };
}
return { data: null, error: null };
}
execute(): MockQueryResult<T> {
if (this.mode === "update" || this.mode === "delete") {
return this.runMutation();
}
// No mock data - return empty array
let filtered: unknown[] = [];
for (const filter of this.filters) {
filtered = filtered.filter((row) => {
const r = row as Record<string, unknown>;
const rowValue = r[filter.column];
switch (filter.op) {
case "eq":
return rowValue === filter.value;
case "neq":
return rowValue !== filter.value;
case "gt":
return (rowValue as number) > (filter.value as number);
case "gte":
return (rowValue as number) >= (filter.value as number);
case "lt":
return (rowValue as number) < (filter.value as number);
case "lte":
return (rowValue as number) <= (filter.value as number);
case "is":
if (filter.value === null) return rowValue === null;
if (filter.value === undefined) return rowValue === undefined;
return rowValue === filter.value;
case "like":
return (
typeof rowValue === "string" &&
rowValue.includes((filter.value as string).replace(/%/g, ""))
);
case "ilike":
return (
typeof rowValue === "string" &&
rowValue
.toLowerCase()
.includes((filter.value as string).replace(/%/g, "").toLowerCase())
);
case "in":
return Array.isArray(filter.value) && filter.value.includes(rowValue);
default:
return true;
}
});
}
if (this.orderColumn) {
filtered.sort((a: unknown, b: unknown) => {
const aVal = (a as Record<string, unknown>)[this.orderColumn!] as string | number;
const bVal = (b as Record<string, unknown>)[this.orderColumn!] as string | number;
if (aVal < bVal) return this.orderDirection === "desc" ? 1 : -1;
if (aVal > bVal) return this.orderDirection === "desc" ? -1 : 1;
return 0;
});
}
if (this.rangeMin !== undefined && this.rangeMax !== undefined) {
filtered = filtered.slice(this.rangeMin, this.rangeMax + 1);
} else if (this.limitValue !== undefined) {
filtered = filtered.slice(0, this.limitValue);
}
return { data: filtered as T[], error: null };
}
private runMutation(): MockQueryResult<T> {
if (this.mode === "delete") {
return { data: null, error: null };
}
if (this.mutationData) {
const items = [this.mutationData];
const returning = items.map((item, i) => ({
...item,
id: (item as Record<string, unknown>).id ?? `generated-${Date.now()}-${i}`,
}));
return { data: returning as unknown as T[], error: null };
}
return { data: null, error: null };
}
}
interface MockMutationResult<T> {
data: T[] | null;
error: null;
}
class MockMutationBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
private tableName: string;
private data: unknown;
constructor(tableName: string, data: unknown) {
this.tableName = tableName;
this.data = data;
}
select() {
return new MockQueryBuilder<T>(this.tableName);
}
eq() {
return this;
}
then<TResult1 = MockMutationResult<T>, TResult2 = never>(
onfulfilled?: ((value: MockMutationResult<T>) => TResult1 | PromiseLike<TResult1>) | null,
): PromiseLike<TResult1 | TResult2> {
const result: MockMutationResult<T> = {
data: Array.isArray(this.data)
? this.data.map((item: Record<string, unknown>, i: number) => ({
...item,
id: item?.id ?? `generated-${Date.now()}-${i}`,
})) as unknown as T[]
: this.data
? [{ ...(this.data as Record<string, unknown>), id: (this.data as Record<string, unknown>).id ?? `generated-${Date.now()}` }] as unknown as T[]
: null,
error: null,
};
return Promise.resolve(onfulfilled ? onfulfilled(result) : (result as unknown as TResult1));
}
}
class MockDeleteBuilder {
eq(_column: string, _value: unknown) {
return this;
}
in(_column: string, _values: unknown[]) {
return this;
}
then(resolve: (value: unknown) => void) {
resolve({ data: null, error: null });
}
}
class MockStorageBuilder {
from(_bucket: string) {
return {
upload: async (_path: string, _file: unknown) => ({ data: { path: _path }, error: null }),
download: async (_path: string) => ({ data: new Blob(), error: null }),
remove: async (_paths: string[]) => ({ data: { paths: _paths }, error: null }),
list: async () => ({ data: [], error: null }),
};
}
}
// Auth credential types
interface SignInCredentials {
email?: string;
password?: string;
}
interface UserAttributes {
email?: string;
data?: Record<string, unknown>;
}
function createMockClient() {
// The query builder is mutable, so we can't simply return a class
// instance + spread mutation methods. The cleanest way to support
// both `.from(t).select(...)...` (read) and `.from(t).update(d).eq(...)`
// (write) in a single chain is to delegate everything to one object
// and inspect `this.mode` lazily. We build that object via
// `Object.assign` to keep the TypeScript inference happy.
function makeFrom<T extends Record<string, unknown> = Record<string, unknown>>(table: string) {
const qb = new MockQueryBuilder<T>(table);
return Object.assign(qb, {
insert: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
update: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
upsert: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
delete: () => new MockDeleteBuilder(),
});
}
return {
from: makeFrom,
storage: new MockStorageBuilder(),
auth: {
getSession: async () => ({ data: { session: null }, error: null }),
getUser: async () => ({ data: { user: null }, error: null }),
signInWithPassword: async (_creds: SignInCredentials) => ({
data: { user: null, session: null },
error: null,
}),
signOut: async () => ({ error: null }),
updateUser: async (_attrs: UserAttributes): Promise<{ data: { user: null }; error: null }> => ({
data: { user: null },
error: null,
}),
onAuthStateChange: () => ({
data: { subscription: { unsubscribe: () => {} } },
}),
},
rpc: async (_name: string, _params?: Record<string, unknown>): Promise<{ data: null; error: null }> => ({
data: null,
error: null,
}),
channel: () => ({
on: () => ({ subscribe: () => ({}) }),
subscribe: () => ({}),
}),
};
}
export const supabase = createMockClient();
-8
View File
@@ -1,8 +0,0 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local
-92
View File
@@ -1,92 +0,0 @@
-- ============================================================================
-- Grok Fix Prompt: admin_create_stop (copy-paste ready for Supabase SQL Editor)
-- ============================================================================
-- This is the exact "Best Practice Version" from the fix prompt, with
-- additional comments. Run this directly in the Supabase SQL Editor
-- (https://supabase.com/dashboard/project/wnzkhezyhnfzhkhiflrp/sql) if the
-- migration push cannot be used.
--
-- After running, PostgREST may need a schema reload:
-- NOTIFY pgrst, 'reload schema';
--
-- Then test "Add New Stop" again.
--
-- The calling code (JS/TS) is in:
-- src/actions/stops/create-stop.ts (the fetch to /rest/v1/rpc/admin_create_stop)
-- src/components/admin/AddStopModal.tsx
-- src/components/admin/NewStopForm.tsx
-- (all go through the server action createStop which builds the p_* args)
-- ============================================================================
CREATE OR REPLACE FUNCTION public.admin_create_stop(
p_active boolean,
p_address text,
p_brand_id uuid,
p_city text,
p_cutoff_time time,
p_date date,
p_location text,
p_state text,
p_time time,
p_zip text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
new_stop_id uuid;
BEGIN
INSERT INTO stops (
active,
address,
brand_id,
city,
cutoff_time,
date,
location,
state,
time,
zip
)
VALUES (
p_active,
p_address,
p_brand_id,
p_city,
p_cutoff_time,
p_date,
p_location,
p_state,
p_time,
p_zip
)
RETURNING id INTO new_stop_id;
RETURN jsonb_build_object(
'success', true,
'stop_id', new_stop_id,
'message', 'Stop created successfully'
);
EXCEPTION WHEN OTHERS THEN
RETURN jsonb_build_object(
'success', false,
'error', SQLERRM
);
END;
$$;
-- Important: grant so PostgREST (anon key from server action) can invoke it.
GRANT EXECUTE ON FUNCTION public.admin_create_stop(
boolean, text, uuid, text, time, date, text, text, time, text
) TO anon, authenticated, service_role;
-- Reload cache so the function is immediately visible (avoids PGRST202).
NOTIFY pgrst, 'reload schema';
-- NOTE: The above simple version may fail on INSERT because the stops table
-- requires "slug" and "status" (NOT NULL, no defaults in all cases).
-- Prefer the enhanced version in migrations/202_fix_admin_create_stop.sql
-- which includes slug generation and status='draft' while using a compatible
-- signature and the success/stop_id return shape.
-408
View File
@@ -1,408 +0,0 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "route_commerce-main"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
# This feature is only available on the hosted platform.
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
# Configure WebAuthn relying party settings (required when passkey is enabled).
# [auth.webauthn]
# rp_display_name = "Supabase"
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth callback URL derived from auth.external_url.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
# [experimental.pgdelta]
# When enabled, pg-delta becomes the active engine for supported schema flows.
# enabled = false
# Directory under `supabase/` where declarative files are written.
# declarative_schema_path = "./database"
# JSON string passed through to pg-delta SQL formatting.
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"

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